Categories
JavaScript React

How to Add Browser Notifications to Your React App

With the HTML5 Notification API, browsers can display native popup notifications to users. With notifications, you can display text and icons, and also play sound with them. The full list of options is located at https://developer.mozilla.org/en-US/docs/Web/API/notification. Users have to grant permission to display notifications when they visit a web app to see browser notifications.

Developers have done the hard work for us if we use React because a React component is created to display browser notifications. The React-Web-Notification package, located at https://www.npmjs.com/package/react-web-notification can let us display popups and handle the events that are associated with display the notifications like when use clicks on the notification or handle cases when permissions or granted or denied for display notifications.

In this article, we will build a password manager that lets you enter, edit and delete passwords to the websites and show notifications whenever these actions are taken. We will use React to build the app.

To start, we will run Create React App to create the app. Run:

npx create-react-app password-manager

to create the app. Next, we add our own libraries, we will use Axios for making HTTP requests to our back end, Formik and Yup for form value handling and form validation respectively, MobX for state management, React Bootstrap for styling, React-Copy-To-Clipboard for letting us copy data to the clipboard, and React Router for routing.

We install them by running:

npm i axios formik mobx mobx-react react-bootstrap react-copy-to-clipboard react-router-fom yup react-web-notifications

With all the libraries installed, we can start building our app. We create all the files in the src folder unless otherwise specified.

First, we replace the existing code in App.css with:

.bg-primary {  
  background-color: #09d3ac !important;  
}

to change the top bar’s background color. Next in App.js , replace the current code with:

import React from "react";  
import { Router, Route } from "react-router-dom";  
import HomePage from "./HomePage";  
import { createBrowserHistory as createHistory } from "history";  
import Navbar from "react-bootstrap/Navbar";  
import Nav from "react-bootstrap/Nav";  
import "./App.css";  
const history = createHistory();

function App({ passwordsStore }) {  
  return (  
    <div className="App">  
      <Router history={history}>  
        <Navbar bg="primary" expand="lg" variant="dark">  
          <Navbar.Brand href="#home">Password Manager</Navbar.Brand>  
          <Navbar.Toggle aria-controls="basic-navbar-nav" />  
          <Navbar.Collapse id="basic-navbar-nav">  
            <Nav className="mr-auto">  
              <Nav.Link href="/" active>Home</Nav.Link>  
            </Nav>  
          </Navbar.Collapse>  
        </Navbar>  
        <Route  
          path="/"  
          exact  
          component={props => (  
            <HomePage {...props} passwordsStore={passwordsStore} />  
          )}  
        />  
      </Router>  
    </div>  
  );  
}export default App;

to add our React Bootstrap top bar and our route to the home page. passwordStore is our MobX store for storing our password list in the front end.

Next create HomePage.css and add:

.home-page {  
  padding: 20px;  
}

to add some padding to our page.

Then create HomePage.js and add:

import React from "react";  
import { useState, useEffect } from "react";  
import Table from "react-bootstrap/Table";  
import ButtonToolbar from "react-bootstrap/ButtonToolbar";  
import Button from "react-bootstrap/Button";  
import Modal from "react-bootstrap/Modal";  
import PasswordForm from "./PasswordForm";  
import "./HomePage.css";  
import { deletePassword, getPasswords } from "./requests";  
import { observer } from "mobx-react";  
import { CopyToClipboard } from "react-copy-to-clipboard";  
import Notification from "react-web-notification";

function HomePage({ passwordsStore }) {  
  const [openAddModal, setOpenAddModal] = useState(false);  
  const [openEditModal, setOpenEditModal] = useState(false);  
  const [initialized, setInitialized] = useState(false);  
  const [selectedPassword, setSelectedPassword] = useState({});  
  const [notificationTitle, setNotificationTitle] = React.useState(""); const openModal = () => {  
    setOpenAddModal(true);  
  }; 

  const closeModal = () => {  
    setOpenAddModal(false);  
    setOpenEditModal(false);  
    getData();  
  }; 

  const cancelAddModal = () => {  
    setOpenAddModal(false);  
  }; 

  const editPassword = contact => {  
    setSelectedPassword(contact);  
    setOpenEditModal(true);  
  }; 

  const cancelEditModal = () => {  
    setOpenEditModal(false);  
  }; 

  const getData = async () => {  
    const response = await getPasswords();  
    passwordsStore.setPasswords(response.data);  
    setInitialized(true);  
  }; 

  const deleteSelectedPassword = async id => {  
    await deletePassword(id);  
    setNotificationTitle("Password deleted");  
    getData();  
  }; 

  useEffect(() => {  
    if (!initialized) {  
      getData();  
    }  
  }); 

  return (  
    <div className="home-page">  
      <h1>Password Manager</h1>  
      <Modal show={openAddModal} onHide={closeModal}>  
        <Modal.Header closeButton>  
          <Modal.Title>Add Password</Modal.Title>  
        </Modal.Header>  
        <Modal.Body>  
          <PasswordForm  
            edit={false}  
            onSave={closeModal.bind(this)}  
            onCancelAdd={cancelAddModal}  
            passwordsStore={passwordsStore}  
          />  
        </Modal.Body>  
      </Modal> <Modal show={openEditModal} onHide={closeModal}>  
        <Modal.Header closeButton>  
          <Modal.Title>Edit Password</Modal.Title>  
        </Modal.Header>  
        <Modal.Body>  
          <PasswordForm  
            edit={true}  
            onSave={closeModal.bind(this)}  
            contact={selectedPassword}  
            onCancelEdit={cancelEditModal}  
            passwordsStore={passwordsStore}  
          />  
        </Modal.Body>  
      </Modal>  
      <ButtonToolbar onClick={openModal}>  
        <Button variant="outline-primary">Add Password</Button>  
      </ButtonToolbar>  
      <br />  
      <div className="table-responsive">  
        <Table striped bordered hover>  
          <thead>  
            <tr>  
              <th>Name</th>  
              <th>URL</th>  
              <th>Username</th>  
              <th>Password</th>  
              <th></th>  
              <th></th>  
              <th></th>  
              <th></th>  
            </tr>  
          </thead>  
          <tbody>  
            {passwordsStore.passwords.map(c => (  
              <tr key={c.id}>  
                <td>{c.name}</td>  
                <td>{c.url}</td>  
                <td>{c.username}</td>  
                <td>******</td>  
                <td>  
                  <CopyToClipboard text={c.username}>  
                    <Button  
                      variant="outline-primary"  
                      onClick={() => setNotificationTitle("Username copied")}  
                    >  
                      Copy Username to Clipboard  
                    </Button>  
                  </CopyToClipboard>  
                </td>  
                <td>  
                  <CopyToClipboard text={c.password}>  
                    <Button  
                      variant="outline-primary"  
                      onClick={() => setNotificationTitle("Password copied")}  
                    >  
                      Copy Password to Clipboard  
                    </Button>  
                  </CopyToClipboard>  
                </td>  
                <td>  
                  <Button  
                    variant="outline-primary"  
                    onClick={editPassword.bind(this, c)}  
                  >  
                    Edit  
                  </Button>  
                </td>  
                <td>  
                  <Button  
                    variant="outline-primary"  
                    onClick={deleteSelectedPassword.bind(this, c.id)}  
                  >  
                    Delete  
                  </Button>  
                </td>  
              </tr>  
            ))}  
          </tbody>  
        </Table>  
      </div> {notificationTitle ? (  
        <Notification  
          title={notificationTitle}  
          options={{  
            icon:  
              "http://mobilusoss.github.io/react-web-notification/example/Notifications_button_24.png"  
          }}  
          onClose={() => setNotificationTitle(undefined)}  
        />  
      ) : null}  
    </div>  
  );  
}  
export default observer(HomePage);

This component is the home page of our app. We have a table to display the list of passwords, a button to add a login and password entry, and buttons in each row of the table to copy username and password, and edit and delete each entry. We have the name, URL, username and password columns. The CopyToClipboard component allows us to copy the data we copy to the text prop of the component. Any component can be inside this component. We have one React Bootstrap modal for add password and another one for edit. PasswordForm is our form for adding the password entries, which we will create later.

We show the notifications whenever a username or password is copied, and when an entry is deleted. We do this by setting the notification title with the setNotificationTitle function. We add an onClose handler in the Notification component so that the notification will display again once it is closed.

We have the openModal , closeModal , cancelAddModal , and cancelEditModal functions to open and close the modals. In the editPassword function, we call the setSelectedPassword function to set the password entry to be edited.

The observer we wrap around the HomePage component is for letting us watch the latest values from passwordsStore.

Next, we modify index.js to have:

import React from "react";  
import ReactDOM from "react-dom";  
import "./index.css";  
import App from "./App";  
import * as serviceWorker from "./serviceWorker";  
import { PasswordsStore } from "./store";  
const passwordsStore = new PasswordsStore();

ReactDOM.render(  
  <App passwordsStore={passwordsStore} />,  
  document.getElementById("root")  
);

// If you want your app to work offline and load faster, you can change  
// unregister() to register() below. Note this comes with some pitfalls.  
// Learn more about service workers: [https://bit.ly/CRA-PWA](https://bit.ly/CRA-PWA)  
serviceWorker.unregister();

We pass in our PasswordStore MobX store here, which will pass it to all the other components.

Next, we create PasswordForm.js and add:

import React 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 PropTypes from "prop-types";  
import { addPassword, getPasswords, editPassword } from "./requests";  
import Notification from "react-web-notification";

const schema = yup.object({  
  name: yup.string().required("Name is required"),  
  url: yup  
    .string()  
    .url()  
    .required("URL is required"),  
  username: yup.string().required("Username is required"),  
  password: yup.string().required("Password is required")  
});

function PasswordForm({  
  edit,  
  onSave,  
  contact,  
  onCancelAdd,  
  onCancelEdit,  
  passwordsStore  
}) {  
  const [notificationTitle, setNotificationTitle] = React.useState(""); 
  const handleSubmit = async evt => {  
    const isValid = await schema.validate(evt);  
    if (!isValid) {  
      return;  
    }  
    if (!edit) {  
      await addPassword(evt);  
      setNotificationTitle("Password added");  
    } else {  
      await editPassword(evt);  
      setNotificationTitle("Password edited");  
    }  
    const response = await getPasswords();  
    passwordsStore.setPasswords(response.data);  
    onSave();  
  }; 

  return (  
    <>  
      <Formik  
        validationSchema={schema}  
        onSubmit={handleSubmit}  
        initialValues={contact || {}}  
      >  
        {({  
          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="url">  
                <Form.Label>URL</Form.Label>  
                <Form.Control  
                  type="text"  
                  name="url"  
                  placeholder="URL"  
                  value={values.url || ""}  
                  onChange={handleChange}  
                  isInvalid={touched.url && errors.url}  
                />  
                <Form.Control.Feedback type="invalid">  
                  {errors.url}  
                </Form.Control.Feedback>  
              </Form.Group> <Form.Group as={Col} md="12" controlId="username">  
                <Form.Label>Username</Form.Label>  
                <Form.Control  
                  type="text"  
                  name="username"  
                  placeholder="Username"  
                  value={values.username || ""}  
                  onChange={handleChange}  
                  isInvalid={touched.username && errors.username}  
                />  
                <Form.Control.Feedback type="invalid">  
                  {errors.username}  
                </Form.Control.Feedback>  
              </Form.Group> <Form.Group as={Col} md="12" controlId="password">  
                <Form.Label>Password</Form.Label>  
                <Form.Control  
                  type="password"  
                  name="password"  
                  placeholder="Password"  
                  value={values.password || ""}  
                  onChange={handleChange}  
                  isInvalid={touched.password && errors.password}  
                />  
                <Form.Control.Feedback type="invalid">  
                  {errors.password}  
                </Form.Control.Feedback>  
              </Form.Group>  
            </Form.Row>  
            <Button type="submit" style={{ marginRight: "10px" }}>  
              Save  
            </Button>  
            <Button type="button" onClick={edit ? onCancelEdit : onCancelAdd}>  
              Cancel  
            </Button>  
          </Form>  
        )}  
      </Formik>  
      {notificationTitle ? (  
        <Notification  
          title={notificationTitle}  
          options={{  
            icon:  
              "http://mobilusoss.github.io/react-web-notification/example/Notifications_button_24.png"  
          }}  
          onClose={() => setNotificationTitle(undefined)}  
        />  
      ) : null}  
    </>  
  );  
}

PasswordForm.propTypes = {  
  edit: PropTypes.bool,  
  onSave: PropTypes.func,  
  onCancelAdd: PropTypes.func,  
  onCancelEdit: PropTypes.func,  
  contact: PropTypes.object,  
  contactsStore: PropTypes.object  
};

export default PasswordForm;

Here, we add our form for letting users enter the username and password of their websites. We use the Yup schema object we created at the top of our code to make sure all fields are entered and check that the URL entered is actually a URL. We use the Formik component to handle the form of input changes and get the latest values.

Once the form is checked to be valid by schema.validate promise to resolve to true , then addPassword or editPassword functions from requests.js , which we will create later will be called depending if the user is adding or editing an entry. Once that succeeds, then the getPasswords from the same file is called, and then setPasswords from passwordsStore is called to store the passwords in the store. Finally, onSave passed in from the props in HomePage component is called to close the modal.

We show the notifications whenever an entry is added or edited, and when an entry is deleted. We do this by setting the notification title with the setNotificationTitle function. We add an onClose handler in the Notification component so that the notification will display again once it is closed.

Next, create requests.js and add:

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

export const getPasswords = () => axios.get(`${APIURL}/passwords`);
export const addPassword = (data) => axios.post(`${APIURL}/passwords`, data);
export const editPassword = (data) => axios.put(`${APIURL}/passwords/${data.id}`, data);
export const deletePassword = (id) => axios.delete(`${APIURL}/passwords/${id}`);

to let us make the requests to our back end to save the password entries.

Then we create our MobX store by creating store.js and add:

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

class PasswordsStore {  
  passwords = [];
  setPasswords(passwords) {  
    this.passwords = passwords;  
  }  
}

PasswordsStore = decorate(PasswordsStore, {  
  passwords: observable,  
  setPasswords: action  
});

export { PasswordsStore };

We have the passwords field which can be observed for the latest value if we wrap the observer function provided by MobX outside a component. The setPasswords is used to set the latest password entries in the store so that they can be propagated to the components.

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/](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>Password Manager</title>  
    <link  
      rel="stylesheet"  
      href="[https://maxcdn.bootstrapcdn.com/bootstrap/4.3.1/css/bootstrap.min.css](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>

to change the title and add the Bootstrap CSS.

Now we can run the app by running set PORT=3001 && react-scripts start on Windows or PORT=3006 react-scripts start on Linux.

To start the back end, we first install the json-server package by running npm i json-server. Then, go to our project folder and run:

json-server --watch db.json

In db.json, change the text to:

{  
  "passwords": [  
  ]  
}

So we have the passwords endpoints defined in the requests.js available.

Categories
JavaScript React TypeScript

How to Use the Optional Chaining Operator in Your React App Right Now

Optional chaining is a proposed feature that may be incorporated into the JavaScript specification.

The operator allows you to traverse through a nested object to get the value of variables without worrying if any of those will be undefined.

For example, without optional chaining, if you have the object below:

const person = {  
  name: 'Alice',  
  cat: {  
    name: 'Bob'  
  }  
};

If you want to get the cat’s name, you have to use the code below:

const catName = person.cat.name;

If cat is undefined or null in person, the JavaScript interpreter will throw an error. With the optional chaining operator, you can write:

const catName = person?.cat?.name;

If cat is undefined, the catName will be null.

It also works with keys of an object. Instead of const catName = person?.cat?.name;, we can write:

const catName = person?.['cat']?.['name'];

This syntax also works with functions. For example, you can write:

func?.('foo')

To call the function func with a string, where func may be undefined or null. If the function does not exist, it will not be run.

To further illustrate the example and to show you how to use it in a real application, we will build a React app that uses the NASA API, to get the latest asteroid data.

We will use the Create React App CLI program to build the app.

As optional chaining is just a proposed feature, it is not currently supported with the CLI, so we have to do some work ourselves by installing some packages and making some changes to the Babel configuration of the app to enable optional chaining.

To start, we run npx create-react-app nasa-app to create the project folder with the initial files.

Next, we install npm i -D @babel/plugin-proposal-optional-chaining customize-cra react-app-rewired to begin customizing Create React App to support the optional chaining syntax.

Next, we have to add new configuration files and edit existing ones to let our app run and build with the syntax.

First, we add a file called config-overrides.js and add the following:

const { useBabelRc, override, useEslintRc } = require("customize-cra");
module.exports = override(useBabelRc());

To let us use the .babelrc configuration file.

Then, we create the .babelrc file in the root folder of our project and add:

{  
    "plugins": [  
        [  
            "@babel/plugin-proposal-optional-chaining"  
        ],  
    ]  
}

This will add support for optional chaining syntax in our project.

Next, we have to switch to make our app run and build with react-app-rewired instead of the usual react-script program.

To do this, in the scripts section of package.json, we put:

"scripts": {  
    "start": "react-app-rewired start",  
    "build": "react-app-rewired build",  
    "test": "react-app-rewired test --env=jsdom",  
    "eject": "react-scripts eject"  
}

In here, we replaced the original scripts that use react-script to run and build our app, with react-app-rewired.

Now we can use the optional chaining syntax to build our app.

First, we have to install some packages.

Run npm i axios bootstrap react-bootstrap formik yup react-router-dom to install the axios HTTP client, React Bootstrap for styling, Formik and Yup for building forms and adding form validation, and React Router for routing URLs to the pages we build.

Now we can write some code. In App.js, we replace the existing code with:

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

function App() {  
  return (  
    <div className="App">  
      <Router history={history}>  
        <TopBar />  
        <Route path="/" exact component={HomePage} />  
        <Route path="/search" exact component={AsteroidsSearchPage} />  
      </Router>  
    </div>  
  );  
}export default App;

So that we get client-side routing to our pages. In App.css, we replace the existing code with:

.center {  
  text-align: center;  
}

Next, we start building new pages. Create a file called AsteroidSearchPage.js in the src folder and add:

import React, { useState, useEffect } 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 Card from "react-bootstrap/Card";  
import "./AsteroidsSearchPage.css";  
import { searchFeed } from "./requests";

const schema = yup.object({  
  startDate: yup  
    .string()  
    .required("Start date is required")  
    .matches(  
      /([12]\d{3}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01]))/,  
      "Invalid start date"  
    ),  
  endDate: yup  
    .string()  
    .required("End date is required")  
    .matches(  
      /([12]\d{3}-(0[1-9]|1[0-2])-(0[1-9]|\[12]\d|3\[01]))/,  
      "Invalid end date"  
    ),  
});

function AsteroidsSearchPage() {  
  const [feed, setFeed] = useState({});  
  const [error, setError] = useState("");
  const handleSubmit = async evt => {  
    const isValid = await schema.validate(evt);  
    if (!isValid) {  
      return;  
    }  
    try {  
      const response = await searchFeed(evt);  
      setFeed(response.data.near_earth_objects);  
    } catch (ex) {  
      alert(ex?.response?.data?.error_message);  
    }  
  };

  return (  
    <div className="AsteroidsSearchPage">  
      <h1 className="center">Search Asteroids</h1>  
      <Formik validationSchema={schema} onSubmit={handleSubmit}>  
        {({  
          handleSubmit,  
          handleChange,  
          handleBlur,  
          values,  
          touched,  
          isInvalid,  
          errors,  
        }) => (  
          <Form noValidate onSubmit={handleSubmit}>  
            <Form.Row>  
              <Form.Group as={Col} md="12" controlId="startDate">  
                <Form.Label>Start Date</Form.Label>  
                <Form.Control  
                  type="text"  
                  name="startDate"  
                  placeholder="YYYY-MM-DD"  
                  value={values.startDate || ""}  
                  onChange={handleChange}  
                  isInvalid={touched.startDate && errors.startDate}  
                />  
                <Form.Control.Feedback type="invalid">  
                  {errors.startDate}  
                </Form.Control.Feedback>  
              </Form.Group>  
              <Form.Group as={Col} md="12" controlId="endDate">  
                <Form.Label>End Date</Form.Label>  
                <Form.Control  
                  type="text"  
                  name="endDate"  
                  placeholder="YYYY-MM-DD"  
                  value={values.endDate || ""}  
                  onChange={handleChange}  
                  isInvalid={touched.startDate && errors.endDate}  
                /><Form.Control.Feedback type="invalid">  
                  {errors.endDate}  
                </Form.Control.Feedback>  
              </Form.Group>  
            </Form.Row>  
            <Button type="submit" style={{ marginRight: "10px" }}>  
              Search  
            </Button>  
          </Form>  
        )}  
      </Formik>  
      {Object.keys(feed)?.map(key => {  
        return (  
          <Card style={{ width: "90vw", margin: "0 auto" }}>  
            <Card.Body>  
              <Card.Title>{key}</Card.Title>  
              {feed?.[key]?.length > 0  
                ? feed?.[key]?.map(f => {  
                    return (  
                      <div style={{ paddingBottom: "10px" }}>  
                        {f?.close_approach_data?.length > 0 ? (  
                          <div>  
                            <b>  
                              Close Approach Date:  
                              {f?.close_approach_data?.map(d => {  
                                return <p>{d?.close_approach_date_full}</p>;  
                              })}  
                            </b>  
                          </div>  
                        ) : null}  
                        <div>  
                          Minimum Estimated Diameter:{" "}  
                          {  
                            f?.estimated_diameter?.kilometers  
                              ?.estimated_diameter_min  
                          }{" "}  
                          km  
                        </div>  
                        <div>  
                          Maximum Estimated Diameter:{" "}  
                          {  
                            f?.estimated_diameter?.kilometers  
                              ?.estimated_diameter_max  
                          }{" "}  
                          km  
                          <br />  
                        </div>  
                      </div>  
                    );  
                  })  
                : null}  
            </Card.Body>  
          </Card>  
        );  
      })}  
    </div>  
  );  
}

export default AsteroidsSearchPage;

This is where we add a form to search for asteroid data from the NASA API by date range. Both the start and end date fields should be in YYYY-MM-DD format and we changed our form validation to match that in the schema object.

Once validation is done, by calling the schema.validate function, we search. The response has many nested objects, so we use the optional chaining syntax everywhere in the result cards.

We loop through close_approach_data array, and we don’t assume it’s always defined or has a greater than zero length, the same goes for the call to the map function. We do not assume that the map function is always defined.

We also use the optional chaining syntax for f?.estimated_diameter?.kilometers ?.estimated_diameter_min and f?.estimated_diameter?.kilometers ?.estimated_diameter_max.

The more levels of nesting there are, the less likely it is that you can traverse the object tree successfully without the optional chaining syntax, as there is more chance of nested objects being undefined.

Also, note that the optional chaining syntax works for returned results like Object.keys(feed)

In AsteroidSearchPage.css, which we should create in the src folder, we put:

.AsteroidsSearchPage{  
  margin: 0 auto;  
  width: 90vw;  
}

To add some margins to the page.

Next, we build the home page. Create a file called HomePage.js in the src folder and add:

import React, { useState, useEffect } from "react";  
import { browse } from "./requests";  
import Card from "react-bootstrap/Card";  
import "./HomePage.css";

function HomePage() {  
  const [initialized, setIntialized] = useState(false);  
  const [feed, setFeed] = useState([]);
  const browserFeed = async () => {  
    const response = await browse();  
    setFeed(response.data.near_earth_objects);  
    setIntialized(true);  
  };

  useEffect(() => {  
    if (!initialized) {  
      browserFeed();  
    }  
  });  
  return (  
    <div className="HomePage">  
      <h1 className='center'>Asteroids Close to Earth</h1>  
      <br />  
      {feed?.map(f => {  
        return (  
          <Card style={{ width: "90vw", margin: "0 auto" }}>  
            <Card.Body>  
              <Card.Title>{f?.name}</Card.Title>  
              <div>  
                {f?.close_approach_data?.length > 0 ? (  
                  <div>  
                    Close Approach Date:  
                    {f?.close_approach_data?.map(d => {  
                      return <p>{d?.close_approach_date_full}</p>;  
                    })}  
                  </div>  
                ) : null}  
                <p>  
                  Minimum Estimated Diameter:{" "}  
                  {f?.estimated_diameter?.kilometers?.estimated_diameter_min} km  
                </p>  
                <p>  
                  Maximum Estimated Diameter:{" "}  
                  {f?.estimated_diameter?.kilometers?.estimated_diameter_max} km  
                </p>  
              </div>  
            </Card.Body>  
          </Card>  
        );  
      })}  
    </div>  
  );  
}

export default HomePage;

This page is very similar to the AsteroidSearchPage component with the use of the optional chaining syntax.

Next, create HomePage.css in the src folder and add:

.HomePage{  
  text-align: left;  
}

To align our text to the left.

Next, we create requests.js in the src folder and add:

const APIURL = "https://api.nasa.gov/neo/rest/v1]";  
const axios = require("axios");

export const searchFeed = data =>  
  axios.get(  
    `${APIURL}/feed?start_date=${data.startDate}&end_date=${data.endDate}&api_key=${process.env.REACT_APP_APIKEY}`  
  );

export const browse = () =>  
  axios.get(`${APIURL}/neo/browse?api_key=${process.env.REACT_APP_APIKEY}`);

To add the functions for making the HTTP requests to the NASA API for getting asteroid data and searching for them.

process.env.REACT_APP_APIKEY has the API key when you add the API key to the .env file of your project with REACT_APP_APIKEY as the key. Register for an API key at NASA.

Finally, we add TopBar.js to 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">NASA 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.Link href="/search" active={pathname.includes("/search")}>  
            Search  
          </Nav.Link>  
        </Nav>  
      </Navbar.Collapse>  
    </Navbar>  
  );  
}

export default withRouter(TopBar);

This is the navigation bar at the top of each page in our app. We set the active prop by checking the current URL of the page so we get highlights in our links.

Categories
JavaScript Nodejs React

How To Build A Chat App With React, Socket.io, And Express

WebSockets is a great technology for adding real time communication to your apps. It works by allowing apps to send events to another app, passing data along with it. This means that users can see new data on their screen without manually retrieving new data, allowing better interactivity and making the user experience easier for the user. HTTP also has a lot of overhead with sending data that not all apps need like headers, this increases the latency of the communication between apps.

Socket.io is a library that uses both WebSockets and HTTP requests to allow apps to send and receive data between each other. Sending data between apps is almost instant. It works by allow apps to emit events to other apps and the apps receiving the events can handle them the way they like. It also provides namespacing and chat rooms to segregate traffic.

One the best uses of WebSockets and Socket.io is a chat app. Chat apps requires real time communication since messages are sent and received all the time. If we use HTTP requests, we would have to make lots of requests repeatedly to do something similar. It will be very slow and taxing on computing and networking resources if we send requests all the time to get new messages.

In this article, we will build a chat app that allows you to join multiple chat rooms and send messages with different chat handles. Chat handle is the username you use for joining the chat. We will use React for front end, and Express for back end. Socket.io client will be used on the front end and Socket.io server will be used on the back end.

To start we make an empty folder for our project and then inside the folder we make a folder called backend for our back end project. Then we go into the backend folder and run the Express Generator to generate the initial code for the back end app. To do this, run npx express-generator . Then in the same folder, run npm install to install the packages. We will need to add more packages to our back end app. We need Babel to use the latest JavaScript features, including the import syntax for importing modules, which is not yet supported by the latest versions of Node.js. We also need the CORS package to allow front end to communicate with back end. Sequelize is needed for manipulate our database, which we will use for storing chat room and chat message data. Sequelize is a popular ORM for Node.js. We also need the dotenv package to let us retrieve our database credentials from environment variables. Postgres will be our database system of choice to store the data.

We run npm i @babel/cli @babel/core @babel/node @babel/preset-env cors dotenv pg pg-hstore sequelize sequelize-cli socket.io to install the packages. After installing the packages, we will run npx sequelize-cli init in the same folder to add the code needed to use Sequelize for creating models and migrations.

Now we need to configure Babel so that we can run our app with the latest JavaScript syntax. First, create a file called .babelrc in the backend folder and add:

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

Next we replace the scripts section of package.json with:

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

Note that we also have to install nodemon by running npm i -g nodemon so that the app will restart whenever file changes, making it easier for us to develop the app. Now if we run npm start , we should be able to run with the latest JavaScript features in our app.

Next we have to change config.json created by running npx sequelize init . Rename config.json to config.js and replace the existing code with:

require("dotenv").config();  
const dbHost = process.env.DB_HOST;  
const dbName = process.env.DB_NAME;  
const dbUsername = process.env.DB_USERNAME;  
const dbPassword = process.env.DB_PASSWORD;  
const dbPort = process.env.DB_PORT || 5432;

module.exports = {  
  development: {  
    username: dbUsername,  
    password: dbPassword,  
    database: dbName,  
    host: dbHost,  
    port: dbPort,  
    dialect: "postgres",  
  },  
  test: {  
    username: dbUsername,  
    password: dbPassword,  
    database: "chat_app_test",  
    host: dbHost,  
    port: dbPort,  
    dialect: "postgres",  
  },  
  production: {  
    use_env_variable: "DATABASE_URL",  
    username: dbUsername,  
    password: dbPassword,  
    database: dbName,  
    host: dbHost,  
    port: dbPort,  
    dialect: "postgres",  
  },  
};

This is allow us to read the database credentials from our .env located in the backend folder, which should look something like this:

DB_HOST='localhost'  
DB_NAME='chat_app_development'  
DB_USERNAME='postgres'  
DB_PASSWORD='postgres'

Now that we have our database connection configured, we can make some models and migrations. Run npx sequelize model:generate --name ChatRoom --attributes name:string to create the ChatRooms table with the name column and the ChatRoom model in our code along with the associated migration. Next we make the migration and model for storing the messages. Run npx sequelize model:generate --name ChatRoomMessages --attributes author:string,message:text,chatRoomId:integer . Note that in both commands, we use singular word for the model name. There should also be no spaces after the comma in the column definitions.

Next we add a unique constraint to the name column of the ChatRooms table. Create a new migration by running npx sequelize-cli migration:create add-unique-constraint-for-chatroom-name to make an empty migration. Then in there, put:

"use strict";

module.exports = {  
  up: (queryInterface, Sequelize) => {  
    return queryInterface.addConstraint("ChatRooms", ["name"], {  
      type: "unique",  
      name: "unique_name",  
    });  
  }, 

  down: (queryInterface, Sequelize) => {  
    return queryInterface.removeConstraint("ChatRooms", "unique_name");  
  },  
};

After all that is done, we run npx sequelize-cli db:migrate to run the migrations.

Next in bin/www , we add the code for sending and receiving events with Socket.io. Replace the existing code with:

#!/usr/bin/env node
/**
 * Module dependencies.
 */
const app = require("../app");
const debug = require("debug")("backend:server");
const http = require("http");
const models = require("../models");
/**
 * Get port from environment and store in Express.
 */
const port = normalizePort(process.env.PORT || "3000");
app.set("port", port);
/**
 * Create HTTP server.
 */
const server = http.createServer(app);
const io = require("socket.io")(server);
io.on("connection", socket => {
  socket.on("join", async room => {
    socket.join(room);
    io.emit("roomJoined", room);
  });
  socket.on("message", async data => {
    const { chatRoomName, author, message } = data;
    const chatRoom = await models.ChatRoom.findAll({
      where: { name: chatRoomName },
    });
    const chatRoomId = chatRoom[0].id;
    const chatMessage = await models.ChatMessage.create({
      chatRoomId,
      author,
      message: message,
    });
    io.emit("newMessage", chatMessage);
  });
});
/**
 * Listen on provided port, on all network interfaces.
 */
server.listen(port);
server.on("error", onError);
server.on("listening", onListening);
/**
 * Normalize a port into a number, string, or false.
 */
function normalizePort(val) {
  const port = parseInt(val, 10);
  if (isNaN(port)) {
    // named pipe
    return val;
  }
  if (port >= 0) {
    // port number
    return port;
  }
  return false;
}
/**
 * Event listener for HTTP server "error" event.
 */
function onError(error) {
  if (error.syscall !== "listen") {
    throw error;
  }
  const bind = typeof port === "string" ? "Pipe " + port : "Port " + port;
// handle specific listen errors with friendly messages
  switch (error.code) {
    case "EACCES":
      console.error(bind + " requires elevated privileges");
      process.exit(1);
      break;
    case "EADDRINUSE":
      console.error(bind + " is already in use");
      process.exit(1);
      break;
    default:
      throw error;
  }
}
/**
 * Event listener for HTTP server "listening" event.
 */
function onListening() {
  const addr = server.address();
  const bind = typeof addr === "string" ? "pipe " + addr : "port " + addr.port;
  debug("Listening on " + bind);
}

so that the app will listen to connect from clients, and let the join rooms when the join event is received. We process messages received with the message event in this block of code:

socket.on("message", async data => {  
    const { chatRoomName, author, message } = data;  
    const chatRoom = await models.ChatRoom.findAll({  
      where: { name: chatRoomName },  
    });  
    const chatRoomId = chatRoom\[0\].id;  
    const chatMessage = await models.ChatMessage.create({  
      chatRoomId,  
      author,  
      message: message,  
    });  
    io.emit("newMessage", chatMessage);  
  });

and emit a newMessage event once the message sent with the message event is saved by getting the chat room ID and saving everything to the ChatMessages table.

In our models, we have to create a has many relationship between the ChatRooms and ChatMessages table by changing our model code. In chatmessage.js , we put:

'use strict';
module.exports = (sequelize, DataTypes) => {
  const ChatMessage = sequelize.define('ChatMessage', {
    chatRoomId: DataTypes.INTEGER,
    author: DataTypes.STRING,
    message: DataTypes.TEXT
  }, {});
  ChatMessage.associate = function(models) {
    // associations can be defined here
    ChatMessage.belongsTo(models.ChatRoom, {
      foreignKey: 'chatRoomId',
      targetKey: 'id'
    });
  };
  return ChatMessage;
};

to make the ChatMessages table belong to the ChatRooms table.

In ChatRoom.js , we put:

"use strict";  
module.exports = (sequelize, DataTypes) => {  
  const ChatRoom = sequelize.define(  
    "ChatRoom",  
    {  
      name: DataTypes.STRING,  
    },  
    {}  
  );  
  ChatRoom.associate = function(models) {  
    // associations can be defined here  
    ChatRoom.hasMany(models.ChatMessage, {  
      foreignKey: "chatRoomId",  
      sourceKey: "id",  
    });  
  };  
  return ChatRoom;  
};

so that we make each ChatRoom have many ChatMessages .

Next we need to add some routes to our back end for getting and setting chat rooms, and getting messages messages. Create a new file called chatRoom.js in the routes folder and add:

const express = require("express");
const models = require("../models");
const router = express.Router();
/* GET users listing. */
router.get("/chatrooms", async (req, res, next) => {
  const chatRooms = await models.ChatRoom.findAll();
  res.send(chatRooms);
});
router.post("/chatroom", async (req, res, next) => {
  const room = req.body.room;
  const chatRooms = await models.ChatRoom.findAll({
    where: { name: room },
  });
  const chatRoom = chatRooms[0];
  if (!chatRoom) {
    await models.ChatRoom.create({ name: room });
  }
  res.send(chatRooms);
});
router.get("/chatroom/messages/:chatRoomName", async (req, res, next) => {
  try {
    const chatRoomName = req.params.chatRoomName;
    const chatRooms = await models.ChatRoom.findAll({
      where: {
        name: chatRoomName,
      },
    });
    const chatRoomId = chatRooms[0].id;
    const messages = await models.ChatMessage.findAll({
      where: {
        chatRoomId,
      },
    });
    res.send(messages);
  } catch (error) {
    res.send([]);
  }
});
module.exports = router;

The /chatrooms route get all the chat rooms from the database. The chatroom POST route adds a new chat room if it does not yet exist by looking up any existing one by name. The /chatroom/messages/:chatRoomName route gets the messages for a given chat room by chat room name.

Finally 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 indexRouter = require("./routes/index");
var chatRoomRouter = require("./routes/chatRoom");
var app = express();
const cors = require("cors");

// view engine setup
app.set("views", path.join(__dirname, "views"));
app.set("view engine", "jade");
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(cors());
app.use("/", indexRouter);
app.use("/chatroom", chatRoomRouter);
// 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;

and add our chat room routes by adding:

app.use("/chatroom", chatRoomRouter);

Now that back end is done, we can build our front end. Go to the project’s root folder and run npx create-react-app frontend . This create the initial code for front end with the packages installed. Next we need to install some packages ourselves. Run npm i axios bootstrap formik react-bootstrap react-router-dom socket.io-client yup to install our Axios HTTP client, Bootstrap for styling, React Router for routing URLs to our pages, and Formik and Yup for easy form data handling and validation respectively.

After we installed our packages, we can write some code. All files we change are in the src folder except when the path is mentioned explicitly. First, in App.js , we change the existing code to the following:

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 "./App.css";  
import ChatRoomPage from "./ChatRoomPage";  
const history = createHistory();function App() { return (  
    <div className="App">  
      <Router history={history}>  
        <TopBar />  
        <Route path="/" exact component={HomePage} />  
        <Route path="/chatroom" exact component={ChatRoomPage} />  
      </Router>  
    </div>  
  );  
}

export default App;

To define our routes and include the top bar in our app, which will build later. Then in App.css , replace the existing code with:

.App {  
  margin: 0 auto;  
}

Next create a new page called ChatRoomPage.js and add the following:

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 io from "socket.io-client";
import "./ChatRoomPage.css";
import { getChatRoomMessages, getChatRooms } from "./requests";
const SOCKET_IO_URL = "http://localhost:3000";
const socket = io(SOCKET_IO_URL);
const getChatData = () => {
  return JSON.parse(localStorage.getItem("chatData"));
};
const schema = yup.object({
  message: yup.string().required("Message is required"),
});
function ChatRoomPage() {
  const [initialized, setInitialized] = useState(false);
  const [messages, setMessages] = useState([]);
  const [rooms, setRooms] = useState([]);
  const handleSubmit = async evt => {
    const isValid = await schema.validate(evt);
    if (!isValid) {
      return;
    }
    const data = Object.assign({}, evt);
    data.chatRoomName = getChatData().chatRoomName;
    data.author = getChatData().handle;
    data.message = evt.message;
    socket.emit("message", data);
  };
  const connectToRoom = () => {
    socket.on("connect", data => {
      socket.emit("join", getChatData().chatRoomName);
    });
    socket.on("newMessage", data => {
      getMessages();
    });
    setInitialized(true);
  };
  const getMessages = async () => {
    const response = await getChatRoomMessages(getChatData().chatRoomName);
    setMessages(response.data);
    setInitialized(true);
  };
  const getRooms = async () => {
    const response = await getChatRooms();
    setRooms(response.data);
    setInitialized(true);
  };
  useEffect(() => {
   if (!initialized) {
      getMessages();
      connectToRoom();
      getRooms();
    }
  });
  return (
    <div className="chat-room-page">
      <h1>
        Chat Room: {getChatData().chatRoomName}. Chat Handle:{" "}
        {getChatData().handle}
      </h1>
      <div className="chat-box">
        {messages.map((m, i) => {
          return (
            <div className="col-12" key={i}>
              <div className="row">
                <div className="col-2">{m.author}</div>
                <div className="col">{m.message}</div>
                <div className="col-3">{m.createdAt}</div>
              </div>
            </div>
          );
        })}
      </div>
      <Formik validationSchema={schema} onSubmit={handleSubmit}>
        {({
          handleSubmit,
          handleChange,
          handleBlur,
          values,
          touched,
          isInvalid,
          errors,
        }) => (
          <Form noValidate onSubmit={handleSubmit}>
            <Form.Row>
              <Form.Group as={Col} md="12" controlId="handle">
                <Form.Label>Message</Form.Label>
                <Form.Control
                  type="text"
                  name="message"
                  placeholder="Message"
                  value={values.message || ""}
                  onChange={handleChange}
                  isInvalid={touched.message && errors.message}
                />
                <Form.Control.Feedback type="invalid">
                  {errors.message}
                </Form.Control.Feedback>
              </Form.Group>
            </Form.Row>
            <Button type="submit" style={{ marginRight: "10px" }}>
              Send
            </Button>
          </Form>
        )}
      </Formik>
    </div>
  );
}
export default ChatRoomPage;

This contains our main chat room code. The user will see the content of this page after going through the home page where they will fill out their chat handle and chat room name. First we connect to our Socket.io server by running const socket = io(SOCKET_IO_URL); Then, we connect to the given chat room name , which we stored in local storage in the connectToRoom function. The function will have the handler for the connect event, which is executed after the connect event is received. Once the event is received, the the client emits the join event by running socket.emit(“join”, getChatData().chatRoomName); , which sends the join event with our chat room name. Once the join event is received by the server. It will call the socket.join function in its event handler. Whenever the user submits a message the handleSubmit function is called, which emits the message event to our Socket.io server. Once the message is delivered to the server, it will save the message to the database and then emit the newMessage event back to the front end. The front end will then get the latest messages using the route we defined in back end using an HTTP request.

Note that we send the chat data to the server via Socket.io instead of HTTP requests, so that all users in the chat room will get the same data right away since the newMessage event will be broadcasted to all clients.

We create a file called ChatRoom.css , then in the file, add:

.chat-room-page {
  width: 90vw;
  margin: 0 auto;
}
.chat-box {
  height: calc(100vh - 300px);
  overflow-y: scroll;
}

Next we create the home page, which is the first page that the users sees when the user first opens the app. It is where the user will enter their chat handle and the name of the chat room. Create a file called HomePage.js 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 { Redirect } from "react-router";
import "./HomePage.css";
import { joinRoom } from "./requests";
const schema = yup.object({
  handle: yup.string().required("Handle is required"),
  chatRoomName: yup.string().required("Chat room is required"),
});
function HomePage() {
  const [redirect, setRedirect] = useState(false);
  const handleSubmit = async evt => {
    const isValid = await schema.validate(evt);
    if (!isValid) {
      return;
    }
    localStorage.setItem("chatData", JSON.stringify(evt));
    await joinRoom(evt.chatRoomName);
    setRedirect(true);
  };
  if (redirect) {
    return <Redirect to="/chatroom" />;
  }
  return (
    <div className="home-page">
      <h1>Join Chat</h1>
      <Formik
        validationSchema={schema}
        onSubmit={handleSubmit}
        initialValues={JSON.parse(localStorage.getItem("chatData") || "{}")}
      >
        {({
          handleSubmit,
          handleChange,
          handleBlur,
          values,
          touched,
          isInvalid,
          errors,
        }) => (
          <Form noValidate onSubmit={handleSubmit}>
            <Form.Row>
              <Form.Group as={Col} md="12" controlId="handle">
                <Form.Label>Handle</Form.Label>
                <Form.Control
                  type="text"
                  name="handle"
                  placeholder="Handle"
                  value={values.handle || ""}
                  onChange={handleChange}
                  isInvalid={touched.handle && errors.handle}
                />
                <Form.Control.Feedback type="invalid">
                  {errors.firstName}
                </Form.Control.Feedback>
              </Form.Group>
              <Form.Group as={Col} md="12" controlId="chatRoomName">
                <Form.Label>Chat Room Name</Form.Label>
                <Form.Control
                  type="text"
                  name="chatRoomName"
                  placeholder="Chat Room Name"
                  value={values.chatRoomName || ""}
                  onChange={handleChange}
                  isInvalid={touched.chatRoomName && errors.chatRoomName}
                />
                <Form.Control.Feedback type="invalid">
                  {errors.chatRoomName}
                </Form.Control.Feedback>
              </Form.Group>
            </Form.Row>
            <Button type="submit" style={{ marginRight: "10px" }}>
              Join
            </Button>
          </Form>
        )}
      </Formik>
    </div>
  );
}
export default HomePage;

Once the user enters the data into the form, it will be checked if they are filled in and once they are, a request will be sent to back end to add the chat room if it is not there. We also save the filled in data to local storage and redirect the user to the chat room page, where they will connect to the chat room with the name that they entered.

Both forms are built with React Bootstrap’s Form component.

Next we create a file called HomePage.css and add:

.home-page {  
    width: 90vw;  
    margin: 0 auto;  
}

to add some margins to our page.

Then we create a file called requests.js in the src folder to add the code for making the requests to our server for manipulating chat rooms and getting chat messages. In the file, add the following code:

const APIURL = "http://localhost:3000";  
const axios = require("axios");  
export const getChatRooms = () => axios.get(`${APIURL}/chatroom/chatrooms`);
export const getChatRoomMessages = chatRoomName =>  
  axios.get(`${APIURL}/chatroom/chatroom/messages/${chatRoomName}`);
export const joinRoom = room =>  
  axios.post(`${APIURL}/chatroom/chatroom`, { room });

Finally, in we create the top bar. Create a file called TopBar.js 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">Chat Room 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 == "/"}>
            Join Another Chat Room
          </Nav.Link>
        </Nav>
      </Navbar.Collapse>
    </Navbar>
  );
}
export default withRouter(TopBar);

We create the top bar using the Navbar widget provided by React Bootstrap with a link to the home page. We wrap the component with the withRouter function so that we get the location object from React Router.

Categories
JavaScript React

Use React Refs to Manipulate the DOM and Konva to Add Graphics

React is a flexible framework that provides structured UI code while allowing the flexibility to manipulate DOM elements directly. All React components can be accessed by their refs. A React ref provides access to the underlying HTML DOM element that you can manipulate directly.

To use refs, we use the React.createRef function the useRef hook to create a ref object and then assign it to a variable. he variable is set as the value of the ref prop.

For example, we define a ref with:

const inputRef = React.createRef();

Then in the input element, we add the following:

<input type="text" ref={inputRef} />

Then we can access the input element by adding:

inputRef.current

This provides access to the HTML element, and we can utilize native DOM functionality. In our app, we will use the useRef hook, and you will see how to implement that below.

What we are building

In this article, we will build a whiteboard app that allows users to add shapes, texts, lines, and images to a whiteboard. In addition, users can undo their work and erase stuff from the screen.

We use the Konva library to let us add the shapes/text and erase them. The Konva library abstracts the hard work of adding items to the canvas. It allows you to add many shapes by simply writing a few lines of code. There are also React bindings for Konva, which abstracts some functionality even further for React. However, the feature set of React Konva is rather limited, so in order to meet the requirements of most apps, React Konva should be used as a companion to Konva.

We also want to allow users to move and transform your shapes easily, which you would have to write yourself if you wanted to do it in the HTML Canvas API.

Konva works by creating a stage and a layer in the stage which will allow you to add the lines, shapes, and text that you want.

Getting started

To start, we will create a React app with the Create React App command line program. Run npx create-react-app whiteboard-app to create the initial files for our app. Next, we need to add some packages. We want to use Bootstrap for styling, in addition to the Konva packages, and helper package for creating unique IDs for our shapes, lines, and text. We also need React Router for routing.

To install the libraries, we run:

npm i bootstrap react-bootstrap konva react-konva react-router-dom use-image uuid

use-image is a package to convert image URLs into image objects that can be displayed on canvas. The UUID package generates unique IDs for our shapes.

With the packages installed, we can start writing code. First we start with the entry point of our app, which is App.js. Replace the existing code of the file 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 "./App.css";  
const history = createHistory();
function App() {  
  return (  
    <div className="App">  
      <Router history={history}>  
        <TopBar />  
        <Route path="/" exact component={HomePage} />  
      </Router>  
    </div>  
  );  
}

export default App;

All we added is a top navigation bar and our only route which is the home page.

Next we add the code for the shapes. React Konva has libraries for common shapes like rectangles and circles. We first start with a circle. In the src
folder, create a file called Circle.js and add:

import React from "react";  
import { Circle, Transformer } from "react-konva";
const Circ = ({ shapeProps, isSelected, onSelect, onChange }) => {  
  const shapeRef = React.useRef();  
  const trRef = React.useRef();
  React.useEffect(() => {  
    if (isSelected) {  
      trRef.current.setNode(shapeRef.current);  
      trRef.current.getLayer().batchDraw();  
    }  
  }, [isSelected]);

  return (  
    <React.Fragment>  
      <Circle  
        onClick={onSelect}  
        ref={shapeRef}  
        {...shapeProps}  
        draggable  
        onDragEnd={e => {  
          onChange({  
            ...shapeProps,  
            x: e.target.x(),  
            y: e.target.y(),  
          });  
        }}  
        onTransformEnd={e => {  
          // transformer is changing scale  
          const node = shapeRef.current;  
          const scaleX = node.scaleX();  
          const scaleY = node.scaleY();  
          node.scaleX(1);  
          node.scaleY(1);  
          onChange({  
            ...shapeProps,  
            x: node.x(),  
            y: node.y(),  
            width: node.width() * scaleX,  
            height: node.height() * scaleY,  
          });  
        }}  
      />  
      {isSelected && <Transformer ref={trRef} />}  
    </React.Fragment>  
  );  
};

export default Circ;

This code returns the Circle shape which can be added onto the canvas at will. In the React.useEffect’s callback function, we detect if the shape is selected and then draw a handle for the shape so that it can be resized and moved.

In this file, we added refs to the Circle component so that we can access it in the useEffect callback. The setNode function takes an HTML DOM element.

The component in the return statement is the main code for the Circle . We have an onClick handler that gets the ID of the selected shape. The draggable prop makes the Circle draggable. onDragEnd handles the event when the user stops dragging. The position is updated there. onTransformEnd scales the shape as the user drags the handles that are available. The width and height are changed as the handles are dragged. {isSelected && <Transformer ref={trRef} />} create the Transformer object, which is a Konva object that lets you change the size of a shape when you select.

Next we add a component for the image. Create a file called Image.js in the src folder and add the following:

import React from "react";  
import { Image, Transformer } from "react-konva";  
import useImage from "use-image";
const Img = ({ shapeProps, isSelected, onSelect, onChange, imageUrl }) => {  
  const shapeRef = React.useRef();  
  const trRef = React.useRef();  
  const [image] = useImage(imageUrl); 

  React.useEffect(() => {  
    if (isSelected) {  
      trRef.current.setNode(shapeRef.current);  
      trRef.current.getLayer().batchDraw();  
    }  
  }, [isSelected]); 
  return (  
    <React.Fragment>  
      <Image  
        onClick={onSelect}  
        image={image}  
        ref={shapeRef}  
        draggable  
        onDragEnd={e => {  
          onChange({  
            ...shapeProps,  
            x: e.target.x(),  
            y: e.target.y(),  
          });  
        }}  
        onTransformEnd={e => {  
          const node = shapeRef.current;  
          const scaleX = node.scaleX();  
          const scaleY = node.scaleY();  
          onChange({  
            ...shapeProps,  
            x: node.x(),  
            y: node.y(),  
            width: node.width() * scaleX,  
            height: node.height() * scaleY,  
          });  
        }}  
      />  
      {isSelected && <Transformer ref={trRef} />}  
    </React.Fragment>  
  );  
};

export default Img;

This is very similar to the Circle component except we have the useImage function provided by the use-image library to convert the given imageUrl prop to an image that is displayed on the canvas.

In this file, we added refs to the Image component so that we can access it in the useEffect callback. The setNode function takes an HTML DOM element.

Next we create a free drawing line. Create a file called line.js in the src folder and add:

import Konva from "konva";

export const addLine = (stage, layer, mode = "brush") => {  
  let isPaint = false;  
  let lastLine; stage.on("mousedown touchstart", function(e) {  
    isPaint = true;  
    let pos = stage.getPointerPosition();  
    lastLine = new Konva.Line({  
      stroke: mode == "brush" ? "red" : "white",  
      strokeWidth: mode == "brush" ? 5 : 20,  
      globalCompositeOperation:  
        mode === "brush" ? "source-over" : "destination-out",  
      points: [pos.x, pos.y],  
      draggable: mode == "brush",  
    });  
    layer.add(lastLine);  
  }); 

  stage.on("mouseup touchend", function() {  
    isPaint = false;  
  }); 

  stage.on("mousemove touchmove", function() {  
    if (!isPaint) {  
      return;  
    } const pos = stage.getPointerPosition();  
    let newPoints = lastLine.points().concat(\[pos.x, pos.y\]);  
    lastLine.points(newPoints);  
    layer.batchDraw();  
  });  
};

In this file, we use plain Konva since React Konva does not have a convenient way to make free drawing a line where a user drags the mouse. When the mousedown and touchstart is triggered, we set the color of the line depending on what the mode is. When it is brush, we draw a red line. If it’s erase we draw a thick white line so that users can draw it over their content, letting users erase their changes.

When the mousemove and touchend events are triggered, we set isPaint to false so we stop drawing the line. When the mousemove and touchmove events are triggered, we add dots along the way to draw the line in the direction the user wants when the user moves the mouse when clicking or touching the touchscreen.

stage and layer are the Konva Stage and Layer objects which we pass in when the addLine function is called.

Next we create the Rectangle component for drawing free form rectangles. In the src folder, create a file called Rectangle.js and add:

import React from "react";  
import { Rect, Transformer } from "react-konva";
const Rectangle = ({ shapeProps, isSelected, onSelect, onChange }) => {  
  const shapeRef = React.useRef();  
  const trRef = React.useRef(); 
  React.useEffect(() => {  
    if (isSelected) {  
      // we need to attach transformer manually  
      trRef.current.setNode(shapeRef.current);  
      trRef.current.getLayer().batchDraw();  
    }  
  }, [isSelected]); 

  return (  
    <React.Fragment>  
      <Rect  
        onClick={onSelect}  
        ref={shapeRef}  
        {...shapeProps}  
        draggable  
        onDragEnd={e => {  
          onChange({  
            ...shapeProps,  
            x: e.target.x(),  
            y: e.target.y(),  
          });  
        }}  
        onTransformEnd={e => {  
          // transformer is changing scale  
          const node = shapeRef.current;  
          const scaleX = node.scaleX();  
          const scaleY = node.scaleY();  
          node.scaleX(1);  
          node.scaleY(1);  
          onChange({  
            ...shapeProps,  
            x: node.x(),  
            y: node.y(),  
            width: node.width() * scaleX,  
            height: node.height() * scaleY,  
          });  
        }}  
      />  
      {isSelected && <Transformer ref={trRef} />}  
    </React.Fragment>  
  );  
};export default Rectangle;

This component is similar to Circle component. We have the drag handles to move and resize the rectangle by adding the onDragEnd and onTransformEnd callbacks, change the x and y coordinates in the onDragEnd handler, and change the width and height in the onTransformEnd event callback.

The Transformer component is added if the shape is selected so that users can move or resize the shape with the handles when selected.

Similar to the Circle component, we added refs to the Rectangle component so that we can access it in the useEffect callback. The setNode function takes an HTML DOM element.

Next we add a text field component to let users can add text to the whiteboard. Create a file called textNode.js and add the following:

import Konva from "konva";  
const uuidv1 = require("uuid/v1");
export const addTextNode = (stage, layer) => {  
  const id = uuidv1();  
  const textNode = new Konva.Text({  
    text: "type here",  
    x: 50,  
    y: 80,  
    fontSize: 20,  
    draggable: true,  
    width: 200,  
    id,  
  }); 

  layer.add(textNode); let tr = new Konva.Transformer({  
    node: textNode,  
    enabledAnchors: ["middle-left", "middle-right"],  
    // set minimum width of text  
    boundBoxFunc: function(oldBox, newBox) {  
      newBox.width = Math.max(30, newBox.width);  
      return newBox;  
    },  
  }); 

  stage.on("click", function(e) {  
    if (!this.clickStartShape) {  
      return;  
    }  
    if (e.target._id == this.clickStartShape._id) {  
      layer.add(tr);  
      tr.attachTo(e.target);  
      layer.draw();  
    } else {  
      tr.detach();  
      layer.draw();  
    }  
  }); 

  textNode.on("transform", function() {  
    // reset scale, so only with is changing by transformer  
    textNode.setAttrs({  
      width: textNode.width() \* textNode.scaleX(),  
      scaleX: 1,  
    });  
  }); 

  layer.add(tr); layer.draw(); textNode.on("dblclick", () => {  
    // hide text node and transformer:  
    textNode.hide();  
    tr.hide();  
    layer.draw(); 
    // create textarea over canvas with absolute position  
    // first we need to find position for textarea  
    // how to find it?
    // at first lets find position of text node relative to the stage:  
    let textPosition = textNode.absolutePosition();
    // then lets find position of stage container on the page:  
    let stageBox = stage.container().getBoundingClientRect();
    // so position of textarea will be the sum of positions above:  
    let areaPosition = {  
      x: stageBox.left + textPosition.x,  
      y: stageBox.top + textPosition.y,  
    };
    // create textarea and style it  
    let textarea = document.createElement("textarea");  
    document.body.appendChild(textarea);
    // apply many styles to match text on canvas as close as possible  
    // remember that text rendering on canvas and on the textarea can be different  
    // and sometimes it is hard to make it 100% the same. But we will try...  
    textarea.value = textNode.text();  
    textarea.style.position = "absolute";  
    textarea.style.top = areaPosition.y + "px";  
    textarea.style.left = areaPosition.x + "px";  
    textarea.style.width = textNode.width() - textNode.padding() * 2 + "px";  
    textarea.style.height =  
      textNode.height() - textNode.padding() * 2 + 5 + "px";  
    textarea.style.fontSize = textNode.fontSize() + "px";  
    textarea.style.border = "none";  
    textarea.style.padding = "0px";  
    textarea.style.margin = "0px";  
    textarea.style.overflow = "hidden";  
    textarea.style.background = "none";  
    textarea.style.outline = "none";  
    textarea.style.resize = "none";  
    textarea.style.lineHeight = textNode.lineHeight();  
    textarea.style.fontFamily = textNode.fontFamily();  
    textarea.style.transformOrigin = "left top";  
    textarea.style.textAlign = textNode.align();  
    textarea.style.color = textNode.fill();  
    let rotation = textNode.rotation();  
    let transform = "";  
    if (rotation) {  
      transform += "rotateZ(" + rotation + "deg)";  
    } let px = 0;  
    let isFirefox = navigator.userAgent.toLowerCase().indexOf("firefox") > -1;  
    if (isFirefox) {  
      px += 2 + Math.round(textNode.fontSize() / 20);  
    }  
    transform += "translateY(-" + px + "px)"; textarea.style.transform = transform;  
    textarea.style.height = "auto";  
    // after browsers resized it we can set actual value  
    textarea.style.height = textarea.scrollHeight + 3 + "px"; textarea.focus(); 

    function removeTextarea() {  
      textarea.parentNode.removeChild(textarea);  
      window.removeEventListener("click", handleOutsideClick);  
      textNode.show();  
      tr.show();  
      tr.forceUpdate();  
      layer.draw();  
    } 

    function setTextareaWidth(newWidth) {  
      if (!newWidth) {  
        // set width for placeholder  
        newWidth = textNode.placeholder.length * textNode.fontSize();  
      }  
      // some extra fixes on different browsers  
      let isSafari = /^((?!chrome|android).)\*safari/i.test(navigator.userAgent);  
      let isFirefox = navigator.userAgent.toLowerCase().indexOf("firefox") > -1;  
      if (isSafari || isFirefox) {  
        newWidth = Math.ceil(newWidth);  
      } let isEdge = document.documentMode || /Edge/.test(navigator.userAgent);  
      if (isEdge) {  
        newWidth += 1;  
      }  
      textarea.style.width = newWidth + "px";  
    } 

    textarea.addEventListener("keydown", function(e) {  
      // hide on enter  
      // but don't hide on shift + enter  
      if (e.keyCode === 13 && !e.shiftKey) {  
        textNode.text(textarea.value);  
        removeTextarea();  
      }  
      // on esc do not set value back to node  
      if (e.keyCode === 27) {  
        removeTextarea();  
      }  
    }); 

    textarea.addEventListener("keydown", function(e) {  
      let scale = textNode.getAbsoluteScale().x;  
      setTextareaWidth(textNode.width() * scale);  
      textarea.style.height = "auto";  
      textarea.style.height =  
        textarea.scrollHeight + textNode.fontSize() + "px";  
    }); 

    function handleOutsideClick(e) {  
      if (e.target !== textarea) {  
        removeTextarea();  
      }  
    }  
    setTimeout(() => {  
      window.addEventListener("click", handleOutsideClick);  
    });  
  });  
  return id;  
};

We add a text area, and then we handle the events created by the text area. When the user clicks the text area, a box will with handles will be displayed to let the user move the text area around the canvas. This is what the click handler for the stage is doing. It finds the text area by ID and then attaches a KonvaTransformer to it, adding the box with handles.

We have a transform handler for the textNode text area to resize the text area when the user drags the handles. We have a double click handler to let users enter text when they double click. Most of the code is for styling the text box as close to the canvas as possible so that it will blend into the canvas. Otherwise, it will look strange. We also let users rotate the text area by applying CSS for rotating the text area as the user drags the handles.

In the keydown event handler, we change the size of the text area as the user types to make sure it displays all the text without scrolling.

When the user clicks outside the text area, the box with handles will disappear, letting the user select other items.

The home page is where we put everything together. Create a new file called HomePage.js in the src folder and add:

import React, { useState, useRef } from "react";  
import ButtonGroup from "react-bootstrap/ButtonGroup";  
import Button from "react-bootstrap/Button";  
import "./HomePage.css";  
import { Stage, Layer } from "react-konva";  
import Rectangle from "./Rectangle";  
import Circle from "./Circle";  
import { addLine } from "./line";  
import { addTextNode } from "./textNode";  
import Image from "./Image";  
const uuidv1 = require("uuid/v1");

function HomePage() {  
  const [rectangles, setRectangles] = useState([]);  
  const [circles, setCircles] = useState([]);  
  const [images, setImages] = useState([]);  
  const [selectedId, selectShape] = useState(null);  
  const [shapes, setShapes] = useState([]);  
  const [, updateState] = React.useState();  
  const stageEl = React.createRef();  
  const layerEl = React.createRef();  
  const fileUploadEl = React.createRef(); 
  const getRandomInt = max => {  
    return Math.floor(Math.random() * Math.floor(max));  
  }; 
  const addRectangle = () => {  
    const rect = {  
      x: getRandomInt(100),  
      y: getRandomInt(100),  
      width: 100,  
      height: 100,  
      fill: "red",  
      id: `rect${rectangles.length + 1}`,  
    };  
    const rects = rectangles.concat(\[rect\]);  
    setRectangles(rects);  
    const shs = shapes.concat([`rect${rectangles.length + 1}`]);  
    setShapes(shs);  
  }; 

  const addCircle = () => {  
    const circ = {  
      x: getRandomInt(100),  
      y: getRandomInt(100),  
      width: 100,  
      height: 100,  
      fill: "red",  
      id: `circ${circles.length + 1}`,  
    };  
    const circs = circles.concat([circ]);  
    setCircles(circs);  
    const shs = shapes.concat([`circ${circles.length + 1}`]);  
    setShapes(shs);  
  }; 

  const drawLine = () => {  
    addLine(stageEl.current.getStage(), layerEl.current);  
  }; 

  const eraseLine = () => {  
    addLine(stageEl.current.getStage(), layerEl.current, "erase");  
  }; 

  const drawText = () => {  
    const id = addTextNode(stageEl.current.getStage(), layerEl.current);  
    const shs = shapes.concat([id]);  
    setShapes(shs);  
  }; 
  
  const drawImage = () => {  
    fileUploadEl.current.click();  
  }; 

  const forceUpdate = React.useCallback(() => updateState({}), []); 
  const fileChange = ev => {  
    let file = ev.target.files[0];  
    let reader = new FileReader(); 
    reader.addEventListener(  
      "load",  
      () => {  
        const id = uuidv1();  
        images.push({  
          content: reader.result,  
          id,  
        });  
        setImages(images);  
        fileUploadEl.current.value = null;  
        shapes.push(id);  
        setShapes(shapes);  
        forceUpdate();  
      },  
      false  
    );
    if (file) {  
      reader.readAsDataURL(file);  
    }  
  }; 

  const undo = () => {  
    const lastId = shapes[shapes.length - 1];  
    let index = circles.findIndex(c => c.id == lastId);  
    if (index != -1) {  
      circles.splice(index, 1);  
      setCircles(circles);  
    } 
    index = rectangles.findIndex(r => r.id == lastId);  
    if (index != -1) {  
      rectangles.splice(index, 1);  
      setRectangles(rectangles);  
    } 
    index = images.findIndex(r => r.id == lastId);  
    if (index != -1) {  
      images.splice(index, 1);  
      setImages(images);  
    }  
    shapes.pop();  
    setShapes(shapes);  
    forceUpdate();  
  }; 

  document.addEventListener("keydown", ev => {  
    if (ev.code == "Delete") {  
      let index = circles.findIndex(c => c.id == selectedId);  
      if (index != -1) {  
        circles.splice(index, 1);  
        setCircles(circles);  
      } 
      index = rectangles.findIndex(r => r.id == selectedId);  
      if (index != -1) {  
        rectangles.splice(index, 1);  
        setRectangles(rectangles);  
      } 
      index = images.findIndex(r => r.id == selectedId);  
      if (index != -1) {  
        images.splice(index, 1);  
        setImages(images);  
      }  
      forceUpdate();  
    }  
  }); 

  return (  
    <div className="home-page">  
      <h1>Whiteboard</h1>  
      <ButtonGroup>  
        <Button variant="secondary" onClick={addRectangle}>  
          Rectangle  
        </Button>  
        <Button variant="secondary" onClick={addCircle}>  
          Circle  
        </Button>  
        <Button variant="secondary" onClick={drawLine}>  
          Line  
        </Button>  
        <Button variant="secondary" onClick={eraseLine}>  
          Erase  
        </Button>  
        <Button variant="secondary" onClick={drawText}>  
          Text  
        </Button>  
        <Button variant="secondary" onClick={drawImage}>  
          Image  
        </Button>  
        <Button variant="secondary" onClick={undo}>  
          Undo  
        </Button>  
      </ButtonGroup>  
      <input  
        style={{ display: "none" }}  
        type="file"  
        ref={fileUploadEl}  
        onChange={fileChange}  
      />  
      <Stage  
        width={window.innerWidth * 0.9}  
        height={window.innerHeight - 150}  
        ref={stageEl}  
        onMouseDown={e => {  
          // deselect when clicked on empty area  
          const clickedOnEmpty = e.target === e.target.getStage();  
          if (clickedOnEmpty) {  
            selectShape(null);  
          }  
        }}  
      >  
        <Layer ref={layerEl}>  
          {rectangles.map((rect, i) => {  
            return (  
              <Rectangle  
                key={i}  
                shapeProps={rect}  
                isSelected={rect.id === selectedId}  
                onSelect={() => {  
                  selectShape(rect.id);  
                }}  
                onChange={newAttrs => {  
                  const rects = rectangles.slice();  
                  rects[i] = newAttrs;  
                  setRectangles(rects);  
                }}  
              />  
            );  
          })}  
          {circles.map((circle, i) => {  
            return (  
              <Circle  
                key={i}  
                shapeProps={circle}  
                isSelected={circle.id === selectedId}  
                onSelect={() => {  
                  selectShape(circle.id);  
                }}  
                onChange={newAttrs => {  
                  const circs = circles.slice();  
                  circs\[i\] = newAttrs;  
                  setCircles(circs);  
                }}  
              />  
            );  
          })}  
          {images.map((image, i) => {  
            return (  
              <Image  
                key={i}  
                imageUrl={image.content}  
                isSelected={image.id === selectedId}  
                onSelect={() => {  
                  selectShape(image.id);  
                }}  
                onChange={newAttrs => {  
                  const imgs = images.slice();  
                  imgs[i] = newAttrs;  
                }}  
              />  
            );  
          })}  
        </Layer>  
      </Stage>  
    </div>  
  );  
}

export default HomePage;

This is where we add the buttons to create the shapes. For the shapes provided by React Konva, we create the shapes by adding an object to the array for the shape and then map them to the shape with the properties specified by the object.

For example, to add a rectangle, we create an object, add it to the array by pushing the object and then calling setRectangles and then map them to the actual Rectangle component when we render the canvas. We pass in the onSelect handler so that the user can click on the shape and get the ID of the selected shape. The onChange handler lets us update the properties of an existing shape and then update the corresponding array for the shapes.

Every React Konva shape we add should be inside the Layer component. They provide the place for the shapes to reside. The Stage component provides a place for the Layer to be in. We set the ref prop for the Stage and Layer components so that we can access it directly.

In the call to the addLine function, we get the refs for the Stage and Layer components to get the reference to the Konva Stage and Layer instances so that we can use them in the addLine function. Note that to get the Konva Stage object, we have to call setStage after the the current attribute.

In the Stage component, we have a onMouseDown handler to deselect all shapes when the click is outside all the shapes.

To the undo a change, we keep track of all the shapes with the shapes array and then when the Undo button is clicked, then the last shape is removed from the array and also the corresponding shapes array. For example, if the undo removes a rectangle, then it will be removed from the shapes array and the rectangle array. The shapes array is an array of IDs of all the shapes.

To build the image upload feature, we add an input element which we don’t show, and we use the ref to write a function to let the user click on the hidden file input. Once the file input is clicked the user can choose files and the image is read with the FileReader object into base64, which will be converted to an image displayed on the canvas with the use-image library.

Similarly for letting users delete shapes when a shape is selected with the delete key, we add a key down handler. In the key down handler function, when a delete key event is triggered then the handler will find the shapes in the arrays by ID and delete it. It will also delete it from the shapes array. We defined the forceUpdate function so that the canvas will be updated even when there is DOM manipulation done without going through React. The keydown handler is added by using document.addEventListener which is not React code, so we need to call forceUpdate to re-render according to the new states.

Finally, to finish it off, we add the top bar. Create a file called TopBar.js in the src folder and add:

import React from "react";  
import Navbar from "react-bootstrap/Navbar";  
import Nav from "react-bootstrap/Nav";  
import NavDropdown from "react-bootstrap/NavDropdown";  
import "./TopBar.css";  
import { withRouter } from "react-router-dom";
function TopBar({ location }) {  
  const { pathname } = location; 
  return (  
    <Navbar bg="primary" expand="lg" variant="dark">  
      <Navbar.Brand href="#home">React Canvas 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);

The Navbar component is provided by React Boostrap.

After all the work is done, we get a whiteboard that we can draw on.

Categories
JavaScript React

How To Use Hooks in React

The latest version of React (v16.8 or later) introduced Hooks, which allow us to set state more simply than before. It also makes function components smart, having the equivalent functionality and lifecycle as class-based components.

State management in a single-page app is also important. Without centralized state management, a lot of data has to be passed directly between components, which becomes unmaintainable and confusing very quickly.

Flux architecture remedies this by making apps store the state in a centralized location and the states are stored as immutable objects to prevent accidental modification.

Redux is one of the most popular libraries for state management and, with React Redux, it can connect your Redux store directly to your components, as you will see below.

In this piece, I will create an app with React and React Redux in the simplest way possible.

Create a New React App

Use the create-react-app code generator, created by the developers of React.

Here is the README and full documentation for create-react-app.

The app that will be created is an app that displays data from the Dog API.

To create the app, run npx create-react-app, and follow the instructions. This will create a new app.

Then, you are ready to install React Router. To install it, run npm i react-router-dom.

After that, install @material-ui/core and axios by running npm i @material-ui/core axios.

Material-UI provides the Material Design look to our app, and axios is an HTTP client which works in client-side apps.

In index.js, we have:

import React from 'react';
import ReactDOM from 'react-dom';
import './index.css';
import App from './App';
import * as serviceWorker from './serviceWorker';
import { breedsReducer, imagesReducer } from './reducers';
import { Provider } from 'react-redux'
import { createStore } from 'redux'
import { combineReducers } from 'redux'

const dogApp = combineReducers({
  breeds: breedsReducer,
  images: imagesReducer
})

const store = createStore(dogApp)

ReactDOM.render(
  <Provider store={store}>
    <App />
  </Provider>
, document.getElementById('root')
);

serviceWorker.unregister();

The file above is where the reducers are mapped to states.

As the combineReducers function is called, the store is created, which is then passed into the app, where the mapStateToProps will make the state available to the component as props.

The mapDispatchToProps allows you to set state in your component via a function in the props, as you will see below.

We add reducers to store state in a centrally available location. The states of our app are set here.

We create a file called reducer.js:

import { SET_BREEDS, SET_IMAGES } from './actions';

function breedsReducer(state = {}, action) {
   switch (action.type) {
    case SET_BREEDS:
      state = JSON.parse(JSON.stringify(action.payload));
      return state;
    default:
      return state
   }
}

function imagesReducer(state = [], action) {
   switch (action.type) {
    case SET_IMAGES:
      state = JSON.parse(JSON.stringify(action.payload));
      return state;
    default:
      return state
  }
}
export { breedsReducer, imagesReducer };

In actions.js, we add these constants for our Redux actions:

const SET_BREEDS = 'SET_BREEDS';
const SET_IMAGES = 'SET_IMAGES';export { SET_BREEDS, SET_IMAGES };

In actionCreators.js, we add:

import { SET_BREEDS, SET_IMAGES } from './actions';

const setBreeds = (breeds) => {
  return {
    type: SET_BREEDS,
    payload: breeds
  }
};

const setImages = (images) => {
  return {
    type: SET_IMAGES,
    payload: images
  }
};
export { setBreeds, setImages };

In app.js, we change the default code to:

import React, { useState, useEffect } from "react";
import './App.css';
import { setBreeds } from './actionCreators';
import { connect } from 'react-redux';
import { Router, Route, Link } from "react-router-dom";
import BreedsPage from './BreedsPage';
import AppBar from '@material-ui/core/AppBar';
import Toolbar from '@material-ui/core/Toolbar';
import Typography from '@material-ui/core/Typography';
import Button from '@material-ui/core/Button';
import IconButton from '@material-ui/core/IconButton';
import Drawer from '@material-ui/core/Drawer';
import List from '@material-ui/core/List';
import ListItem from '@material-ui/core/ListItem';
import ListItemText from '@material-ui/core/ListItemText';
import { makeStyles } from '@material-ui/core/styles';
import { createBrowserHistory as createHistory } from 'history'
const history = createHistory();
const axios = require('axios');
const useStyles = makeStyles(theme => ({
  root: {
    flexGrow: 1,
  },
  menuButton: {
    marginRight: theme.spacing(2),
  },
  title: {
    flexGrow: 1,
  },
}));

function App({ setBreeds }) {
  const classes = useStyles();
  const [initialized, setInitialized] = useState(false);
  const [state, setState] = useState({
    openDrawer: false
  });   
  const titles = {
     '/': 'Dog App',
     '/breeds': 'Get Images By Breed - Dog App',
     '/subbreeds': 'Get Images By Breed or Sub-Breed - Dog App'
   }

   history.listen((location, action) => {
     document.title = titles[location.pathname];
     setState({ openDrawer: false });
   });

  const toggleDrawer = (open) => event => {
   if (event.type === 'keydown' && (event.key === 'Tab' || event.key === 'Shift')) {
      return;
    }
    setState({ openDrawer: open });
   };

  const links = {
    'Home': '/',
    'Breeds': '/breeds',
    'Sub-Breeds': '/subbreeds',
   }

  const getBreeds = () => {
    setInitialized(true);
    axios.get('https://dog.ceo/api/breeds/list/all')
    .then((response) => {
      setBreeds(response.data.message);
    })
    .catch((error) => {
      console.log(error);
    })
    .finally(() => {
    });
   }
  useEffect(() => {
     if (!initialized) {
       getBreeds();
     }
  });

  return (
      <div className="App">
        <Router history={history}>
          <Drawer anchor="left" open={state.openDrawer} onClose=     {toggleDrawer(false)}>
            <List>
               <ListItem button>
                 <h2><b>Dog App</b></h2>
               </ListItem>
               {Object.keys(links).map((text) => (
                  <ListItem button key={text}>
                    <Link to={links[text]}>
                      <ListItemText primary={text} />
                   </Link>
                </ListItem>
                ))}
            </List>
          </Drawer>
           <AppBar position="static">
             <Toolbar>
               <IconButton edge="start" className={classes.menuButton} color="inherit" aria-label="Menu" onClick={toggleDrawer(true)}>
                 <i className="material-icons">menu</i>
               </IconButton>
               <Typography variant="h6" className={classes.title}>Dog App</Typography>
             </Toolbar>
          </AppBar>
          <Route path="/breeds/" component={BreedsPage} />
        </Router>
       </div>
      );
}

const mapStateToProps = (state) => ({
  breeds: state.breeds
})

const mapDispatchToProps = (dispatch) => ({
  setBreeds: breeds => dispatch(setBreeds(breeds))
})

export default connect(
  mapStateToProps,
  mapDispatchToProps
)(App);

The code above has Hooks.

const [initialized, setInitialized] = useState(false);
const [state, setState] = useState({
  openDrawer: false
});

These functions are equivalent to setState functions in class-based components.

The first element in the array (initialized) is equivalent to this.state.initialized and setInitialized is equivalent to a function that calls this.setState({initialized: initializedValue}); in a class-based component.

Hooks only work with function-based components. The benefit is writing fewer lines of code to achieve the same effect of setting state.

Also, note that we have this in the above component:

useEffect(() => {
  if (!initialized) {
    getBreeds();
  }
});

As we don’t have componentDidMount, like we do in class-based components, we have to check if the component is loaded with our own flag.

The getBreeds function sets the initialized flag to true once it has run successfully so that the getBreeds function will not repeatedly run forever.

useEffect is a function that is run during every render, so be careful not to put necessary code in there.

Note the connect function at the end of the file above. This where the state connects to the component.

setBreeds is a function which returns a plain object with the action type and the payload. This allows the reducer to set the state according to the type field, which in this case would be SET_BREEDS or SET_IMAGES.

The state will be set, returned, and the new state will be available via props.breeds for breeds.

The <Route path=”/breeds/” component={BreedsPage} /> is where the route is defined. It must be inside <Router history={history}></Router>. This is the routing part of our application.

With this, we can go to the page with http://localhost:3000/breeds.

This block sets the title and hides the app drawer on the left when the route changes:

history.listen((location, action) => {
   document.title = titles[location.pathname];
   setState({ openDrawer: false });
});

We now create the pages for our app, which will be used by React Router for routing.

First, we create a page for displaying breeds, we will call it BreedPage.js.

The code will look like this:

import React from 'react';
import './BreedsPage.css';
import { setImages } from './actionCreators';
import { connect } from 'react-redux';
import InputLabel from '@material-ui/core/InputLabel';
import MenuItem from '@material-ui/core/MenuItem';
import FormControl from '@material-ui/core/FormControl';
import Select from '@material-ui/core/Select';
import { makeStyles } from '@material-ui/core/styles';
import ImagesBox from './ImagesBox';
const axios = require('axios');
const useStyles = makeStyles(theme => ({
    formControl: {
        margin: theme.spacing(1),
        width: '90vw',
    },
}));

function BreedsPage({ breeds, setImages }) {
    const classes = useStyles();
    const [state, setState] = React.useState({
        breed: '',
    });

    const [initialized, setInitialized] = React.useState(false);
    const handleChange = name => event => {
        setState({
            ...state,
            [name]: event.target.value,
        });
        if (name == 'breed') {
            getImagesByBreed(event.target.value);
        }
    };

    const getImagesByBreed = (breed) => {
        axios.get(`https://dog.ceo/api/breed/${breed}/images`)
            .then((response) => {
                setImages(response.data.message);
            })
            .catch((error) => {
                console.log(error);
            })
            .finally(() => {
            });
    }

    React.useEffect(() => {
        if (!initialized) {
            setInitialized(true);
            setImages([]);
        }
    });
    return (
        <div className="App">
            <h1>Get Images By Breed</h1>
            <form>
                <FormControl className={classes.formControl}>
                    <InputLabel>Breed</InputLabel>
                    <Select
                        value={state.breed}
                        onChange={handleChange('breed')}
                    >
                        {Object.keys(breeds || {}).map(b =>
                            <MenuItem value={b} key={b}>
                                {b}
                            </MenuItem>
                        )}
                    </Select>
                </FormControl>
                <ImagesBox></ImagesBox>
            </form>
        </div>
    );
}

const mapStateToProps = state => {
    return {
        breeds: state.breeds,
        images: state.images
    }
}

const mapDispatchToProps = dispatch => ({
    setImages: images => dispatch(setImages(images))
})

export default connect(
    mapStateToProps,
    mapDispatchToProps
)(BreedsPage);