2022-08-17T09:56:53Z

The React Mega-Tutorial, Chapter 9: Application Features

By now you have learned most of the React concepts you need to complete this application. This chapter is dedicated to building the remaining features of the application, with the goal of solidifying the knowledge you acquired in previous chapters.

The complete course, including videos for every chapter is available to order from my courses site. Ebook and paperback versions of this course are also available from Amazon. Thank you for your support!

For your reference, here is the complete list of articles in this series:

Submitting Blog Posts

Writing a post is arguably the most important feature of this application. The good news is that with the concepts you have learned in previous chapters and the Microblog API documentation, this feature does not present any new challenges.

In terms of the API, a new blog post must be submitted to the server with a POST request to /api/posts. The only field that needs to be included is text, with the text of the post. The author and the timestamp for the new post are derived by the server from the access token and current time respectively.

The Write component, which implements a form to write blog posts, is shown below.

src/components/Write.js: Blog post write form

import { useState, useEffect, useRef } from 'react';
import Stack from "react-bootstrap/Stack";
import Image from "react-bootstrap/Image";
import Form from 'react-bootstrap/Form';
import InputField from './InputField';
import { useApi } from '../contexts/ApiProvider';
import { useUser } from '../contexts/UserProvider';

export default function Write({ showPost }) {
  const [formErrors, setFormErrors] = useState({});
  const textField = useRef();
  const api = useApi();
  const { user } = useUser();

  useEffect(() => {
    textField.current.focus();
  }, []);

  const onSubmit = async (ev) => {
    ev.preventDefault();
    const response = await api.post("/posts", {
      text: textField.current.value
    });
    if (response.ok) {
      showPost(response.body);
      textField.current.value = '';
    }
    else {
      if (response.body.errors) {
        setFormErrors(response.body.errors.json);
      }
    }
  };

  return (
    <Stack direction="horizontal" gap={3} className="Write">
      <Image
        src={ user.avatar_url + '&s=64' }
        roundedCircle
      />
      <Form onSubmit={onSubmit}>
        <InputField
          name="text" placeholder="What's on your mind?"
          error={formErrors.text} fieldRef={textField} />
      </Form>
    </Stack>
  );
}

This component uses a horizontal Stack with two slots as the main structure. Similarly to how blog posts are rendered, the user's avatar is shown on the left. The right side of the stack includes a form with a single input field named text. This field uses a placeholder text instead of a label, so that the prompt appears inside the field when it is empty. The handling of form errors, autofocusing of the field and submission are done in the same way as in the login and registration forms (see Chapter 7 for details).

The only aspect of this form that is different from previous ones is the showPost prop. When a user writes a blog post, the application needs to immediately add the post to the feed at the top position. Since this component does not know anything about modifying the displayed feed, it accepts a callback function provided by the parent component to perform this action. As far as the Write component is concerned, all that needs to be done after successfully creating a blog post is to call the function passed in showPost with the new post object (which Microblog API returns in the body of the response) as an argument.

The Stack component was given a Write class name so that it is possible to customize the styling of the form. The styles for this form are shown in the next listing.

src/index.css: Styling of the write form

... // <-- no changes to existing styles

.Write {
  margin-bottom: 10px;
  padding: 30px 0px 40px 0px;
  border-bottom: 1px solid #eee;
}

.Write form {
  width: 100%;
}

The Write component is rendered by the Posts component, above the list of posts. Because Posts is used for all the post lists in the application, an option needs to be added for the parent component to indicate if the blog post write form needs to appear in the page or not. The changes to Posts are shown below.

src/components/Posts.js: Write form above posts

... // <-- no changes to existing imports
import Write from './Write';

export default function Posts({ content, write }) {
  ... // <-- no changes to existing logic

  const showPost = (newPost) => {
    setPosts([newPost, ...posts]);
  };

  return (
    <>
      {write && <Write showPost={showPost} />}
      ... // <-- no changes to existing JSX
    </>
  );

}

The component adds a write prop. If this prop has a truthy value, then the JSX for the Write component is rendered above the post list.

The showPost prop required by the Write component is assigned a handler function of the same name. In the function, the posts state variable is updated to contain the new post as the first post, with all the other posts after it. The spread operator is used to generate the updated post list.

To complete this feature, the FeedPage component needs to render Posts with its write prop set to true. The new version of this component is shown in the next listing.

src/pages/FeedPage.js: Feed page with write form

import Body from '../components/Body';
import Posts from '../components/Posts';

export default function FeedPage() {
  return (
    <Body sidebar>
      <Posts write={true} />
    </Body>
  );
}

You should now be able to enter blog posts into the system from the feed page. Figure 9.1 shows how this page looks after a first post was entered into the system.

Figure 9.1: Feed page with write form

User Page Actions

The user page, which currently displays information about a user, can be made more useful by providing some actions, depending on the user being viewed:

  • If the page is the profile of the logged-in user, then provide an edit option to change user details.
  • If the page is for a user that the logged-in user is not currently following, then provide a follow option.
  • If the page is for a user that the logged-in user is already following, then provide an unfollow option.

The complication to implement this is in determining which of the three cases above applies, given a logged-in user and a user being viewed. The straightforward case is when these two users are the same, in which case the "Edit" option should be presented.

To determine if the logged-in user is following a given user or not, a GET request must be sent to /api/me/following/{user_id}. If the response comes back with status code 204, then the user is already followed. If the user isn't being followed, the response will have a 404 status code.

The listing that follows shows the changes that need to be made to the UserPage component to display a button to trigger the correct action out of the three possibilities discussed above.

src/pages/UserPage.js: Action buttons in user page

... // <-- no changes to existing imports
import Button from 'react-bootstrap/Button';
import { useNavigate } from 'react-router-dom';
import { useUser } from '../contexts/UserProvider';
import { useFlash } from '../contexts/FlashProvider';

export default function UserPage() {
  ... // <-- no changes to existing state, references and custom hooks
  const [isFollower, setIsFollower] = useState();
  const { user: loggedInUser } = useUser();
  const flash = useFlash();
  const navigate = useNavigate();

  useEffect(() => {
    (async () => {
      const response = await api.get('/users/' + username);
      if (response.ok) {
        setUser(response.body);
        if (response.body.username !== loggedInUser.username) {
          const follower = await api.get(
            '/me/following/' + response.body.id);
          if (follower.status === 204) {
            setIsFollower(true);
          }
          else if (follower.status === 404) {
            setIsFollower(false);
          }
        }
        else {
          setIsFollower(null);
        }
      }
      else {
        setUser(null);
      }
    })();
  }, [username, api, loggedInUser]);

  const edit = () => {
    // TODO
  };

  const follow = async () => {
    // TODO
  };

  const unfollow = async () => {
    // TODO
  };

  return (
    <Body sidebar>
      {user === undefined ?
        <Spinner animation="border" />
      :
        <>
          {user === null ?
            <p>User not found.</p>
          :
            <>
              <Stack direction="horizontal" gap={4}>
                <Image src={user.avatar_url + '&s=128'} roundedCircle />
                <div>
                  ... // <-- no changes to user details

                  {isFollower === null &&
                    <Button variant="primary" onClick={edit}>
                      Edit
                    </Button>
                  }
                  {isFollower === false &&
                    <Button variant="primary" onClick={follow}>
                      Follow
                    </Button>
                  }
                  {isFollower === true &&
                    <Button variant="primary" onClick={unfollow}>
                      Unfollow
                    </Button>
                  }
                </div>
              </Stack>
              <Posts content={user.id} />
            </>
          }
        </>
      }
    </Body>
  );
}

With these changes, the page has a new state variable isFollower that will be true if the logged-in user is following the user being viewed, or false if it isn't. A value of null will be used to indicate that the logged-in user and the viewed user are the same, and as always, undefined is the initial value of the state variable, indicating that the side effect function hasn't determined which of the three possible states is the correct state to use.

The useUser() hook is used to retrieve the logged-in user. This presents a name collision, since the component already has a state variable called user that represents the user being viewed. To disambiguate, the destructuring assignment used with the hook renames the logged-in user to loggedInUser.

The side effect function obtains the viewed user and stores it in the user state variable, and then needs to determine the value of the isFollower state variable, which determines which of the three possible operations apply. If the viewed user is different from the logged-in user, a second request is sent to the back end to obtain the follow relationship between the two users, which is used to update the isFollower state variable.

The buttons that present the actions to the user are going to have onClick handlers edit(), follow() and unfollow() respectively, all placeholders for now.

In the JSX section, the isFollower state variable is used to determine which of the three buttons to display. In the first render of the component, isFollower is going to be undefined, so none of the buttons will render. Once the side effect function completes and the variable resolves to one of true, false or null, the correct button will display.

Editing User Information

When users view their own profile page, they'll see an "Edit" button. The handler for this button in the UserPage component will redirect to a new route that displays a form with user information fields.

src/pages/UserPage.js: Edit user handler

  const edit = () => {
    navigate('/edit');
  };

The routing in the App component needs to be updated to support a new route, which will be handled by a component called EditUserPage.

src/App.js: Edit user route

// add this import at the top
import EditUserPage from './pages/EditUserPage';

export default function App() {
  ... // <-- no changes to logic in this function

  return (
    ...

    // add this route in the private routes section, above the "*" route
    <Route path="/edit" element={<EditUserPage />} />

    ...
  );
}

The complete implementation of the EditUserPage component is shown below.

src/pages/EditUserPage.js: Edit user form

import { useState, useEffect, useRef } from 'react';
import Form from 'react-bootstrap/Form';
import Button from 'react-bootstrap/Button';
import { useNavigate } from 'react-router-dom';
import Body from '../components/Body';
import InputField from '../components/InputField';
import { useApi } from '../contexts/ApiProvider';
import { useUser } from '../contexts/UserProvider';
import { useFlash } from '../contexts/FlashProvider';

export default function EditUserPage() {
  const [formErrors, setFormErrors] = useState({});
  const usernameField = useRef();
  const emailField = useRef();
  const aboutMeField = useRef();
  const api = useApi();
  const { user, setUser } = useUser();
  const flash = useFlash();
  const navigate = useNavigate();

  useEffect(() => {
    usernameField.current.value = user.username;
    emailField.current.value = user.email;
    aboutMeField.current.value = user.about_me;
    usernameField.current.focus();
  }, [user]);

  const onSubmit = async (event) => {
    event.preventDefault();
    const response = await api.put('/me', {
      username: usernameField.current.value,
      email: emailField.current.value,
      about_me: aboutMeField.current.value,
    });
    if (response.ok) {
      setFormErrors({});
      setUser(response.body);
      flash('Your profile has been updated.', 'success');
      navigate('/user/' + response.body.username);
    }
    else {
      setFormErrors(response.body.errors.json);
    }
  };

  return (
    <Body sidebar={true}>
      <Form onSubmit={onSubmit}>
        <InputField
          name="username" label="Username"
          error={formErrors.username} fieldRef={usernameField} />
        <InputField
          name="email" label="Email"
          error={formErrors.email} fieldRef={emailField} />
        <InputField
          name="aboutMe" label="About Me"
          error={formErrors.about_me} fieldRef={aboutMeField} />
        <Button variant="primary" type="submit">Save</Button>
      </Form>
    </Body>
  );
}

This component renders a form with three fields for the user to change the username, email address or "about me" information. The fields are all instances of the InputField component, and are provisioned with references as seen in previous forms.

Other forms had a side effect function that put the focus on the first field of the form. In this component, the side effect function does that as well, but it also pre-populates the three input fields with their current values, obtained from the user object returned by the useUser() hook. Assigning values to form fields is done through the DOM, using the reference objects assigned to the input fields.

The form submission handler sends a PUT request to /api/me in Microblog API, with the data obtained from the form fields through the references. If the request succeeds, the setUser() function returned by the useUser() hook is called to update the user object in the UserProvider component, a success message is flashed, and a redirect back to the user page is issued.

As with previous forms, in case of an error, the validation messages from the server are loaded in the formErrors state variable, which in turn will make the errors visible below each field.

The JSX code for this form renders the form with the three fields and a submit button, as done in previous forms. Figure 9.2 shows how the edit user form looks.

Figure 9.2: Edit user form

Following and Unfollowing Users

The changes required to follow and unfollow users are surprisingly short. To follow user, a POST request to /api/me/following/{user_id} needs to be sent. To unfollow, a DELETE request to the same URL is used. The two handlers for the UserPage component are shown below.

src/pages/UserPage.js: Follow and unfollow handlers

  const follow = async () => {
    const response = await api.post('/me/following/' + user.id);
    if (response.ok) {
      flash(
        <>
          You are now following <b>{user.username}</b>.
        </>, 'success'
      );
      setIsFollower(true);
    }
  };

  const unfollow = async () => {
    const response = await api.delete('/me/following/' + user.id);
    if (response.ok) {
      flash(
        <>
          You have unfollowed <b>{user.username}</b>.
        </>, 'success'
      );
      setIsFollower(false);
    }
  };

The handlers send the appropriate request to the back end. On success, they flash a confirmation message to the user, and then update the isFollower state variable, so that the "Follow" button becomes "Unfollow" and vice versa.

The first arguments to the flash() function in these two handlers is unusual. Instead of them being plain strings, JSX fragments are used. This makes it possible to use a bold font for the username. Using strings with HTML elements would not work, because as a measure to prevent cross-site scripting (XSS) attacks React escapes all text that is rendered.

With these changes, you can go to the Explore page, click on one or more usernames, and follow them. The blog posts from the users you follow will appear on your feed page along with your own posts.

Changing the Password

An important security feature in all applications is to let users change their passwords. The password is an attribute of the user, so like other attributes, it is changed by sending a PUT request to /api/me. But unlike the other attributes, the password is special in that the user must provide both the old and the new passwords in the body of the request for the change to be accepted by the server, and for that reason it is best to handle it separately.

The change password option can be added to the account dropdown that appears on the right side of the Header component. Add this option between the divider and the logout menu option.

src/components/Header.js: Change password menu option

<NavDropdown.Item as={NavLink} to="/password">
  Change Password
</NavDropdown.Item>

The /password client route needs to be added in the App component. As with other similar routes, it will be mapped to a new component in the pages directory called ChangePasswordPage.

src/App.js: Change password route

// add this import at the top
import ChangePasswordPage from './pages/ChangePasswordPage';

export default function App() {
  ... // <-- no changes to logic in this function

  return (
    ...

    // add this route in the private routes section, above the "*" route
    <Route path="/password" element={<ChangePasswordPage />} />

    ...
  );
}

Below is the ChangePasswordPage component.

src/pages/ChangePasswordPage.js: Change password form

import { useState, useEffect, useRef } from 'react';
import Form from 'react-bootstrap/Form';
import Button from 'react-bootstrap/Button';
import { useNavigate } from 'react-router-dom';
import Body from '../components/Body';
import InputField from '../components/InputField';
import { useApi } from '../contexts/ApiProvider';
import { useFlash } from '../contexts/FlashProvider';

export default function ChangePasswordPage() {
  const [formErrors, setFormErrors] = useState({});
  const oldPasswordField = useRef();
  const passwordField = useRef();
  const password2Field = useRef();
  const navigate = useNavigate();
  const api = useApi();
  const flash = useFlash();

  useEffect(() => {
    oldPasswordField.current.focus();
  }, []);

  const onSubmit = async (event) => {
    event.preventDefault();
    if (passwordField.current.value !== password2Field.current.value) {
        setFormErrors({password2: "New passwords don't match"});
    }
    else {
      const response = await api.put('/me', {
        old_password: oldPasswordField.current.value,
        password: passwordField.current.value
      });
      if (response.ok) {
        setFormErrors({});
        flash('Your password has been updated.', 'success');
        navigate('/me');
      }
      else {
        setFormErrors(response.body.errors.json);
      }
    }
  };

  return (
    <Body sidebar>
      <h1>Change Your Password</h1>
      <Form onSubmit={onSubmit}>
        <InputField
          name="oldPassword" label="Old Password" type="password"
          error={formErrors.old_password} fieldRef={oldPasswordField} />
        <InputField
          name="password" label="New Password" type="password"
          error={formErrors.password} fieldRef={passwordField} />
        <InputField
          name="password2" label="New Password Again" type="password"
          error={formErrors.password2} fieldRef={password2Field} />
        <Button variant="primary" type="submit">Change Password</Button>
      </Form>
    </Body>
  );
}

This form uses similar logic to previous forms. It combines client-side validation for the two password fields, with server-side validation, which ensures the old password is correct, and the new password is not empty.

After a successful response from the PUT request to /api/me, the user is redirected to user profile page, with a flashed message that indicates the success of the change.

Figure 9.3 shows the change password page.

Figure 9.3: Change password page

Password Resets

The last feature of the application that will be added in this chapter is the option for users to request a password reset when they forget their account password. This feature is implemented in two parts:

  • First, the user must click a "Forgot Password" link in the login page to access the password reset request page. On this page, the user must enter their email address. If the email address is valid, the server will send an email with a password reset link.
  • The second part of the reset process occurs when the user clicks the password reset link received by email. The link contains a token that needs to be submitted to the server for verification, along with a new password. If the token is valid, then the password is updated.

The implementation of this feature requires two new routes, which will be /reset-request and /reset. These are public routes that need to be wrapped with the PublicRoute component to ensure that a logged-in user does not have access to them.

src/App.js: Password reset routing updates

// add these imports at the top
import ResetRequestPage from './pages/ResetRequestPage';
import ResetPage from './pages/ResetPage';

export default function App() {
  ... // <-- no changes to logic in this function

  return (
    ...

    // add these routes in the public routes section
    <Route path="/reset-request" element={
      <PublicRoute><ResetRequestPage /></PublicRoute>
    } />
    <Route path="/reset" element={
      <PublicRoute><ResetPage /></PublicRoute>
    } />

    ...
  );
}

As before, the application will temporarily break because the modules imported above do not exist yet.

Requesting a Password Reset

The login page needs a link to a new page in which the user can request a password reset.

src/pages/LoginPage.js: Reset password link

// add this above the registration link
<p>Forgot your password? You can <Link to="/reset-request">reset it</Link>.</p>

The complete implementation of the ResetRequestPage component is shown below.

src/pages/ResetRequestPage.js: Reset request form

import { useState, useEffect, useRef } from 'react';
import Form from 'react-bootstrap/Form';
import Button from 'react-bootstrap/Button';
import Body from '../components/Body';
import InputField from '../components/InputField';
import { useApi } from '../contexts/ApiProvider';
import { useFlash } from '../contexts/FlashProvider';

export default function ResetRequestPage() {
  const [formErrors, setFormErrors] = useState({});
  const emailField = useRef();
  const api = useApi();
  const flash = useFlash();

  useEffect(() => {
    emailField.current.focus();
  }, []);

  const onSubmit = async (event) => {
    event.preventDefault();
    const response = await api.post('/tokens/reset', {
      email: emailField.current.value,
    });
    if (!response.ok) {
      setFormErrors(response.body.errors.json);
    }
    else {
      emailField.current.value = '';
      setFormErrors({});
      flash(
        'You will receive an email with instructions ' +
        'to reset your password.', 'info'
      );
    }
  };

  return (
    <Body>
      <h1>Reset Your Password</h1>
      <Form onSubmit={onSubmit}>
        <InputField
          name="email" label="Email Address"
          error={formErrors.email} fieldRef={emailField} />
        <Button variant="primary" type="submit">Reset Password</Button>
      </Form>
    </Body>
  );
}

The reset request form has a single field, in which the user must enter the email address for the account to reset. The email is submitted in a POST request to the /api/tokens/reset URL, and as a result of this request the user should receive an email with a reset link.

Note that a valid email server must be configured in Microblog API for reset emails to be sent out.

Resetting the Password

The email reset links that Microblog API sends out to users have a query string parameter called token. The value of this token, along with the new chosen password for the account must be sent on a PUT request to the /api/tokens/reset endpoint. If the server determines that the reset token is valid, then it updates the password on the account and returns a response with a 200 status code.

Implementing this part of the process represents a departure from anything else that was done in this application, because in this case the user will be starting a new instance of the application by clicking on the link that is in the email. The URL in the email point to the URL where the React application is running, appended with the /reset path so that the correct client route is invoked.

Microblog API has a configuration variable called PASSWORD_RESET_URL that defines what is the URL that needs to be sent in reset emails. By default, the URL is http://localhost:3000/reset, which will work for a development version of the React front end running on your computer.

The ResetPage component shown below extracts the token from the query string, presents a form that asks the user for the account password, and finally submits both to the server.

src/pages/ResetPage.js: Reset password

import { useState, useEffect, useRef } from 'react';
import Form from 'react-bootstrap/Form';
import Button from 'react-bootstrap/Button';
import { useNavigate, useLocation } from 'react-router-dom';
import Body from '../components/Body';
import InputField from '../components/InputField';
import { useApi } from '../contexts/ApiProvider';
import { useFlash } from '../contexts/FlashProvider';

export default function ResetPage() {
  const [formErrors, setFormErrors] = useState({});
  const passwordField = useRef();
  const password2Field = useRef();
  const navigate = useNavigate();
  const { search } = useLocation();
  const api = useApi();
  const flash = useFlash();
  const token = new URLSearchParams(search).get('token');

  useEffect(() => {
    if (!token) {
      navigate('/');
    }
    else {
      passwordField.current.focus();
    }
  }, [token, navigate]);

  const onSubmit = async (event) => {
    event.preventDefault();
    if (passwordField.current.value !== password2Field.current.value) {
        setFormErrors({password2: "New passwords don't match"});
    }
    else {
      const response = await api.put('/tokens/reset', {
        token,
        new_password: passwordField.current.value
      });
      if (response.ok) {
        setFormErrors({});
        flash('Your password has been reset.', 'success');
        navigate('/login');
      }
      else {
        if (response.body.errors.json.new_password) {
          setFormErrors(response.body.errors.json);
        }
        else {
          flash('Password could not be reset. Please try again.', 'danger');
          navigate('/reset-request');
        }
      }
    }
  };

  return (
    <Body>
      <h1>Reset Your Password</h1>
      <Form onSubmit={onSubmit}>
        <InputField
          name="password" label="New Password" type="password"
          error={formErrors.password} fieldRef={passwordField} />
        <InputField
          name="password2" label="New Password Again" type="password"
          error={formErrors.password2} fieldRef={password2Field} />
        <Button variant="primary" type="submit">Reset Password</Button>
      </Form>
    </Body>
  );
}

The token constant is obtained from the query string included in the URL, which can be obtained from the location object provided by React-Router as search. The URLSearchParams class provided by the browser is used to decode the query string into an object.

The form implemented in this component has two fields for the user to enter the new password twice. Client-side validation ensures that these two fields have the same value.

The side effect function has a small difference. Invoking this route without a token query parameter makes no sense, so as a security check the function redirects to the home page if it finds that there is no token.

The function references token and navigate(), which are defined outside the side effect function. For that reason the React build generated warnings and suggested that these should be added to the side effect function as dependencies.

The submission request includes the token and the password in the body of the request, as required by Microblog API. If the request is successful, the user is redirected to the /login route with a flashed message. If the request fails there are two possibilities. When the reason for the failure is a validation error in the password field, then the formErrors state is updated to show the error to the user. For any error responses that do not include validation issues on the password field, a redirect is used to send the user back to the password reset request page to retry. This could happen if the user attempts to use a reset link after the token has expired.

To test the password reset flow, first make sure you are not logged in. From the login page, click on the reset link and enter your email. Click on the link that you receive a few seconds later and enter a new password for your account. If you do not receive an email from Microblog API, then your email server settings are probably incorrect, so go back to Chapter 5 to review them.

Chapter Summary

  • A convenient pattern to create reusable child components is for parents to create callback functions with customized behaviors and pass them to the child component as props.
  • If you don't like some aspects of how a third-party component works, you may be able to create a wrapper component that overrides and improves the original component.
  • You can pre-populate form fields in a side effect function using DOM APIs and field references.
  • To prevent XSS attacks, React escapes all rendered variables. If you need to store HTML in a variable intended for rendering, use a JSX expression instead of a plain string.
  • Application routes are proper links that can be bookmarked or even shared in emails or other communication channels.

Leave a Comment