• The original address: blog.usejournal.com/mastering-r…
  • Ilya Suzdalnitski
  • Translator: Crop Circle

Note: This article is not intended to introduce the basics of React

Over the years, I’ve come to realize that the only correct way to develop a high-quality React application is to write functional components.

In this article, I’ll briefly introduce functional components and higher-order components. After that, we’ll delve into the bloated React component and refactor it into an elegant solution of multiple composable higher-order components.


Photo byDean Pughon Unsplash

Introduction to function components

Function components are called because they are really plain JavaScript functions. React should contain only function components.

Let’s start by looking at a very simple Class component

class MyComponent extends React.Component {
  render() {
    return (
      <div>
        <h1>Hi, {this.props.name}</h1>
      </div>
    );
  }
}

//simple_class_component.jsx 
Copy the code

Now let’s rewrite the same component as a function component:

const MyComponent = ({name}) => (
  <div>
    <h1>Hi, {name}</h1>
  </div>
);

//simple_functional_component.jsx
Copy the code

As you can see, the functional components are cleaner, shorter, and easier to read. There is also no need to use the this keyword.

Some other benefits:

  • Easy to reason about – Function components are pure functions, which means they will always have the same input and output the same output. Given the name Ilya, the above component will render
<h1> Hi, Ilya >Copy the code
  • Easy to test – Since function components are pure functions, they are easy to predict: given some props, predict that it will render the corresponding structure.
  • To help prevent abuse of component state, use props instead.
  • Encourage reusable and modular code.
  • Don’t let the over-responsible “God” Components take on too much
  • Composability – You can add behavior as needed using higher-order components.

If your component has no methods other than render (), there is no reason to use a class component.

High order component

HOC is a feature in React used to reuse (and isolate) component logic. You’ve probably already encountered hoc-Redux’s Connect as a higher-order component.

Applying HOC to components enhances existing components with new features. This is usually done by adding new props, which are passed to the component. For Redux’s Connect, the component gets new props, which are mapped to the mapStateToProps and mapDispatchToProps functions.

We often need to interact with localStorage, but it is a mistake to interact directly with localStorage within a component because it has side effects. In common React, components should have no side effects. The following simple high-level component adds three new props to the package component and enables it to interact with localStorage.

const withLocalStorage = (WrappedComponent) => { const loadFromStorage = (key) => localStorage.getItem(key); const saveToStorage = (key, value) => localStorage.setItem(key, value); const removeFromStorage = (key) => localStorage.removeItem(key); return (props) => ( <WrappedComponent loadFromStorage={loadFromStorage} saveToStorage={saveToStorage} removeFromStorage={removeFromStorage} {... props} /> ); } //simple_hoc.jsxCopy the code

Then we can simply use withLocalStorage(MyComponent)

Messy Class components


Let me walk you through the components we’ll be using. It is a simple registration form consisting of three fields with some basic form validation.

import React from "react"; import { TextField, Button, Grid } from "@material-ui/core"; import axios from 'axios'; class SignupForm extends React.Component { state = { email: "", emailError: "", password: "", passwordError: "", confirmPassword: "", confirmPasswordError: "" }; getEmailError = email => { const emailRegex = /^(([^<>()\[\]\\.,;:\s@"]+(\.[^<>()\[\]\\.,; : \ s @ "] +) *) | (" + ")) @ ((\ [[0-9] {1, 3} \. [0-9] {1, 3} \. [0-9] {1, 3} \. [0-9] {1, 3} \]) | ((\ [a zA - Z - 0-9] + \.) +[a-zA-Z]{2,}))$/; const isValidEmail = emailRegex.test(email); return ! isValidEmail ? "Invalid email." : ""; }; validateEmail = () => { const error = this.getEmailError(this.state.email); this.setState({ emailError: error }); return ! error; }; getPasswordError = password => { const passwordRegex = /^(? =.*[A-Za-z])(? =.*\d)[A-Za-z\d]{8,}$/; const isValidPassword = passwordRegex.test(password); return ! isValidPassword ? "The password must contain minimum eight characters, at least one letter and one number." : ""; }; validatePassword = () => { const error = this.getPasswordError(this.state.password); this.setState({ passwordError: error }); return ! error; }; getConfirmPasswordError = (password, confirmPassword) => { const passwordsMatch = password === confirmPassword; return ! passwordsMatch ? "Passwords don't match." : ""; }; validateConfirmPassword = () => { const error = this.getConfirmPasswordError( this.state.password, this.state.confirmPassword ); this.setState({ confirmPasswordError: error }); return ! error; }; onChangeEmail = event => this.setState({ email: event.target.value }); onChangePassword = event => this.setState({ password: event.target.value }); onChangeConfirmPassword = event => this.setState({ confirmPassword: event.target.value }); handleSubmit = () => { if ( ! this.validateEmail() || ! this.validatePassword() || ! this.validateConfirmPassword() ) { return; } const data = { email: this.state.email, password: this.state.password }; axios.post(`https://mywebsite.com/api/signup`, data); }; render() { return ( <Grid container spacing={16}> <Grid item xs={4}> <TextField label="Email" value={this.state.email} error={!! this.state.emailError} helperText={this.state.emailError} onChange={this.onChangeEmail} margin="normal" /> <TextField label="Password" value={this.state.password} error={!! this.state.passwordError} helperText={this.state.passwordError} type="password" onChange={this.onChangePassword} margin="normal" /> <TextField label="Confirm Password" value={this.state.confirmPassword} error={!! this.state.confirmPasswordError} helperText={this.state.confirmPasswordError} type="password" onChange={this.onChangeConfirmPassword} margin="normal" /> <Button variant="contained" color="primary" onClick={this.handleSubmit} margin="normal" > Sign Up </Button> </Grid> </Grid> ); } } export default SignupForm; //complex_form.jsCopy the code

The above component is messy, doing many things at once: handling its state, validating form fields, and rendering the form. It’s already 140 lines of code. Adding more features quickly becomes unmaintainable. Can we do better?

Let’s see what we can do.

Need to Recompose library

Recompose is a React utility library for functional and higher-order components. Think of it as lodash for React.

Recompose allows you to enhance functional components by adding state, lifecycle methods, context, and more.

Most importantly, it allows you to have a clear separation of concerns – you can have the primary component dedicated to layout, the higher-level component handling form input, another for form validation, and another for form submission. It’s easy to test!

Elegant functional components


Step 0. Install Recompose

yarn add recompose
Copy the code

Step 1. Extract the State of the input form

We will use the withStateHandlers high-level component from the Recompose library. It will allow us to isolate component state from the component itself. We’ll use it to add form state for email, password, and confirm password fields, as well as event handlers for the above fields.

import { withStateHandlers, compose } from "recompose";

const initialState = {
  email: { value: "" },
  password: { value: "" },
  confirmPassword: { value: "" }
};

const onChangeEmail = props => event => ({
  email: {
    value: event.target.value,
    isDirty: true
  }
});

const onChangePassword = props => event => ({
  password: {
    value: event.target.value,
    isDirty: true
  }
});

const onChangeConfirmPassword = props => event => ({
  confirmPassword: {
    value: event.target.value,
    isDirty: true
  }
});

const withTextFieldState = withStateHandlers(initialState, {
  onChangeEmail,
  onChangePassword,
  onChangeConfirmPassword
});

export default withTextFieldState;

//withTextFieldState.js
Copy the code

The withStateHandlers high-level component is very simple — it accepts the initial state and the object that contains the state handlers. When called, each state handler returns a new state.

Step 2. Extract form validation logic

Now it’s time to extract the form validation logic. We will use the withProps advanced component from Recompose. It allows you to add arbitrary props to an existing component.

We’ll add emailError, passwordError, and confirmPasswordError props with withProps, which will output an error if any of our form fields are invalid.

It should also be noted that the validation logic for each form field is kept in a separate file (for better separation of concerns).

import { withProps } from "recompose"; const getEmailError = email => { if (! email.isDirty) { return ""; } const emailRegex = /^(([^<>()\[\]\\.,;:\s@"]+(\.[^<>()\[\]\\.,; : \ s @ "] +) *) | (" + ")) @ ((\ [[0-9] {1, 3} \. [0-9] {1, 3} \. [0-9] {1, 3} \. [0-9] {1, 3} \]) | ((\ [a zA - Z - 0-9] + \.) +[a-zA-Z]{2,}))$/; const isValidEmail = emailRegex.test(email.value); return ! isValidEmail ? "Invalid email." : ""; }; const withEmailError = withProps(ownerProps => ({ emailError: getEmailError(ownerProps.email) })); export default withEmailError; //withEmailError.jsCopy the code
import { withProps } from "recompose"; const getPasswordError = password => { if (! password.isDirty) { return ""; } const passwordRegex = /^(? =.*[A-Za-z])(? =.*\d)[A-Za-z\d]{8,}$/; const isValidPassword = passwordRegex.test(password.value); return ! isValidPassword ? "The password must contain minimum eight characters, at least one letter and one number." : ""; }; const withPasswordError = withProps(ownerProps => ({ passwordError: getPasswordError(ownerProps.password) })); export default withPasswordError; //withPasswordError.jsCopy the code
import { withProps } from "recompose"; const getConfirmPasswordError = (password, confirmPassword) => { if (! confirmPassword.isDirty) { return ""; } const passwordsMatch = password.value === confirmPassword.value; return ! passwordsMatch ? "Passwords don't match." : ""; }; const withConfirmPasswordError = withProps( (ownerProps) => ({ confirmPasswordError: getConfirmPasswordError( ownerProps.password, ownerProps.confirmPassword ) }) ); export default withConfirmPasswordError; //withConfirmPasswordError.jsCopy the code

Step 3. Extract the form submission logic

In this step, we will extract the form submission logic. We will add the onSubmit handler again using the withProps high-level component.

The handleSubmit function accepts emailError, passwordError, and confirmPasswordError props passed from the previous step, checks for any errors, and if not, requests the parameters to our API.

import { withProps } from "recompose";
import axios from "axios";

const handleSubmit = ({
  email,
  password,
  emailError,
  passwordError,
  confirmPasswordError
}) => {
  if (emailError || passwordError || confirmPasswordError) {
    return;
  }

  const data = {
    email: email.value,
    password: password.value
  };

  axios.post(`https://mywebsite.com/api/signup`, data);
};

const withSubmitForm = withProps(ownerProps => ({
  onSubmit: handleSubmit(ownerProps)
}));

export default withSubmitForm;

//withSubmitForm.js 
Copy the code

Step 4. Magic coming

Finally, combine the higher-order components we created into an enhancer that can be used on our form. We’ll use the compose function in Recompose, which can combine multiple higher-order components.

import { compose } from "recompose";

import withTextFieldState from "./withTextFieldState";
import withEmailError from "./withEmailError";
import withPasswordError from "./withPasswordError";
import withConfirmPasswordError from "./withConfirmPasswordError";
import withSubmitForm from "./withSubmitForm";

export default compose(
    withTextFieldState,
    withEmailError,
    withPasswordError,
    withConfirmPasswordError,
    withSubmitForm
);

//withFormLogic.js
Copy the code

Note the elegance and neatness of this solution. All the necessary logic is simply added on top of another logic to generate an enhancer component.

Take a breath of fresh air

Now let’s look at the SignupForm component itself.

import React from "react"; import { TextField, Button, Grid } from "@material-ui/core"; import withFormLogic from "./logic"; const SignupForm = ({ email, onChangeEmail, emailError, password, onChangePassword, passwordError, confirmPassword, onChangeConfirmPassword, confirmPasswordError, onSubmit }) => ( <Grid container spacing={16}> <Grid item xs={4}> <TextField label="Email" value={email.value} error={!! emailError} helperText={emailError} onChange={onChangeEmail} margin="normal" /> <TextField label="Password" value={password.value} error={!! passwordError} helperText={passwordError} type="password" onChange={onChangePassword} margin="normal" /> <TextField label="Confirm Password" value={confirmPassword.value} error={!! confirmPasswordError} helperText={confirmPasswordError} type="password" onChange={onChangeConfirmPassword} margin="normal" /> <Button variant="contained" color="primary" onClick={onSubmit} margin="normal" > Sign Up </Button> </Grid> </Grid> ); export default withFormLogic(SignupForm); //SignupForm.jsCopy the code

The new refactoring component is very clear and does only one thing – render. The single responsibility principle states that a module should do one thing, and it should do it well. I believe we have achieved this objective.

All the necessary data and input handlers are just passed as props. This in turn makes components very easy to test.

We should always strive for our components to contain no logic at all and only render. Recompose allows us to do this.

Project Source Code

If you run into any problems later, you can download the entire project from Github

Surprise: You can optimize performance using Recompose’s Pure

Recompose has pure, which is a nice higher-order component that allows us to re-render components only when needed. Pure will ensure that the component does not rerender unless any props changes.

import { compose, pure } from "recompose"; . export default compose( pure, withFormLogic )(SignupForm); //pureSignupForm.jsCopy the code

Conclusion:

We should always follow the principle of single responsibility for components, separating logic from presentation. To do this, you should first unwrite the Class component. The main components themselves should be functional and should be responsible for presentation and nothing else. Then add all the necessary state and logic as higher-order components.

Following these rules will make your code clear, easy to read, easy to maintain, and easy to test.