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);

Categories
JavaScript React

How To Use Redux With React

If you want to make a single-page app with React, you need to add routing to your app. React Router is the most popular routing library for React apps. To use it, you have to add the library via npm.

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

To create a new React app, use the create-react-app code generator made by the developers of React. Here are the README and full documentation.

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, you will get 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 a Material Design look to our app and Axios is an HTTP client which works at 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();

First, we add reducers to store state in a centrally available location. We make 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 };

InactionCreators.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 <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 when we type in 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 apps which will use React Router for routing. First, we create a page for displaying breeds, we 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);
Categories
JavaScript React

How To Use React Router With React

If you want to make a single-page app with React, you need to add routing to your app. React Router is the most popular routing library for React apps. To use it, you have to add the library via npm.

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

To create a new React app, use the create-react-app code generator made by the developers of React. Here are the README and full documentation.

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, you will get 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 a Material Design look to our app and Axios is an HTTP client which works at 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();

First, we add reducers to store state in a centrally available location. We make 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 <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 when we type in 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 apps which will use React Router for routing. First, we create a page for displaying breeds, we 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);