DEV Community

loading...

Refactoring an Editable React Form with Hooks

Allison Levine
React/Rails developer ❤️JS, CSS
・7 min read

Every year, around this time, I refactor a long and complex React form I’m responsible for maintaining. I don’t plan it, I just get the urge to do it, like spring cleaning. My teammates have upgraded the version of React-Rails we're using in the past year and hooks are now supported, so this refactor seems like a great opportunity to learn more about them.

Note: You may need to import React modules and files in your own code, if you’re using webpack or Webpacker.

Before the Refactor

My main goal for the refactor is to improve the readability of the form components by implementing a new, flatter component pattern that’s easier to work with. Currently the file structure looks something like this:

/Global
 /Inputs
  GlobalInput1.jsx
  …
  GlobalInput10.jsx
/Posts
 /Form
  Form.jsx

And I’m using a class component for the form. It looks like this:

class PostsForm extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      errors: {},
      post: {},
      validated: false,
    };

    this.fetchPostData = this.fetchPostData.bind(this);
    this.handleChange = this.handleChange.bind(this);
    this.submitPost = this.submitPost.bind(this);
    this.validate = this.validate.bind(this);
  }

  // Fetch data on mount to pre-fill the form, for editing posts
  componentDidMount() {
    this.fetchPostData(url);
  }

  fetchPostData(url) {
    // fetch logic here, setState on success
  }

  // Update state when an input is changed
  handleChange(event) {
    this.setState({
        post: {
          ...this.state.post,
          [event.target.name]: event.target.value,
        },
      },
      // Show real-time error feedback (but only if the form has already been validated/submitted once)
      () => this.state.validated && this.validate()
    );
  }

  validate(event) {
    // Validate fields and return true if there's an error
    const possibleErrors = {
      title: return !(this.state.post["title"] && this.state.post["title"].length > 0)
    }

     // Update state with the errors
    this.setState(
      {
        errors: possibleErrors,
        validated: true,
      }
    );

    // Do we have at least one error?
    const errorsFound = Object.keys(possibleErrors).some(
      field => possibleErrors[field]
    );

    // Prevent submit if there's at least one error
    return errorsFound;
  }

submitPost() {
  // If there are errors and validate function returns true, don't submit the form
  if (this.props.validate(event)) {
      return;
  }
  // otherwise, submit the form
  // post logic here, redirect on success
}

  render() {
    return (
      <div>
        <GlobalInput1 errorIds={["title"]} errors={this.state.errors} handleChange={this.handleChange} id="title" inputValue={this.state.post.title} isRequired={true} label="Title" placeholder="Great Post" type="text" />
       <input id="postButton" name="commit" oMouseUp={this.submitPost} onTouchEnd={this.submitPost} type="submit" value="Submit Post" />
      </div>
    );
  }
}

I’m fetching the form data and using a lifecycle method (componentDidMount) to refresh the form inputs with that fetched data (for editing a post). When someone changes a controlled input, the data is updated in state. On submit the fields are validated and one true error prevents the form from submitting. The actual errors are updated in state so that users can see and correct them. If there are no errors, I submit the data via a post request.

This works, but quickly gets messy for a long, complex form. State management becomes a pain because state only lives in this component and can therefore only be manipulated from this component. So all of the methods that set state, including any input event handler callbacks and validations, also need to live in this one component. State needs to be passed down to inputs as props, possibly through multiple levels of the component tree for more complex form elements.

The downsides of this approach are painfully apparent when there's a bug to hunt down and fix. We've had to track down what a prop or callback actually is, through nested layer after layer.

Using Hooks

Hooks take the best of what class components do — state and lifecycle management — and break it down into “tiny and reusable” utilities that don’t require a class. My initial plan was to create a functional wrapper component that would use hooks to fetch and submit the pre-filled form data. The wrapper would then pass the data to the form component as context, rather than props. Here’s what that looks like in code:

// create our context
const PostsContext = React.createContext({})

const Posts = () => {
   // Use setState() hook to manage our post data, and set it to an empty object initially
   const [post, setPost] = React.useState({});

  // Move our fetch function here
  fetchPostData = (url) => {
     // fetch logic here
     // pass fetched data to setPost() on success
  }

  // Move the post request part of our submit function here
  submitPostData = (data) => {
     // post logic here
  }

  // Render the form with a Context provider wrapper
  return (
     <PostsContext.Provider value={{ post, submitPostData }}>
        <PostsForm />
     </PostsContext.Provider>
  );
}

I’m now using the setContext() hook to create and pass on the data we need to pre-fill the form for editing posts, via the Provider value. I’m also using the useState() hook to manage the state of the data within this new functional component, Posts. To update state at the right time with our fetched data, however, I need to use a third hook, useEffect():

const Posts = () => {
...

   React.useEffect(() => {
      // Move our fetch logic here
      // pass fetched data to setPost() on success
   }, [])

...
}

Here I’m replacing componentDidMount (which can only be used in class components) with useEffect() . useEffect() isn’t an exact match for componentDidMount because it not only runs after render, but also after every update, like componentDidUpdate. But useEffect() takes a second, optional array argument that allows us to specify when there’s been a relevant update. To only fetch the data once, I can pass an empty array as the second argument:

This tells React that your effect doesn’t depend on*any*values from props or state, so it never needs to re-run. This isn’t handled as a special case — it follows directly from how the dependencies array always works. (from Using the Effect Hook – React)

Now my initial data will fetch once, after render, and I can access it from the form component via context:
const { post, submitPostData ) = React.useContext(PostsContext);

So far so good, for fetching and submitting form data. But I realized I had to take this refactor a step further if I wanted to also break up the form’s functionality and validations into “tiny and reusable” parts.

Adding a Custom Hook

I’d never written a custom hook before, but fortunately I found a great video tutorial by Matt Upham here on DEV. Based on the pattern he demonstrated, I created my own useForm() hook that does the following:

  • Manages the state of the form values and errors
  • Validates the form using a form-specific function that’s passed to it
  • Submits the form using a form-specific callback that’s passed to it
  • Returns all this to the form component

Here’s the code:

const useForm = (callback, initialValues, validate) => {
  // HOOKS
  const [values, setValues] = React.useState(initialValues);
  const [errors, setErrors] = React.useState([]);
  const [isSubmitting, setIsSubmitting] = React.useState(false);
  const [isValidated, setIsValidated] = React.useState(false);

  // useEffect hook to submit the form, runs when setErrors is called in handleSubmit because of the [errors] array we're passing as the second argument
  React.useEffect(() => {
    // if there are no errors and submit has been clicked
    if (Object.keys(errors).length === 0 && isSubmitting) {
      // submit the form
      callback(values);
      setIsSubmitting(false);
    } else {
      // show the errors
      scrollFormUp();
      setIsSubmitting(false);
    }
  }, [errors]);

  // useEffect hook to hide and display errors while working on a validated form, runs when values change
  React.useEffect(() => {
    isValidated && setErrors(validate(values));
  }, [values]);

  // CUSTOM METHODS
  // Runs when an input is changed, to update the data in state.
  handleChange = (event) => {
    const { name, value } = event.target;
    setValues({
      ...values,
      [name]: value,
    });
  }

  // Runs when the form is submitted
  handleSubmit = (event) => {
    event.preventDefault();
    // prevent multiple clicks
    if (isSubmitting) {
     return;
    }
    // check for errors (triggers useEffect hook to submit the form)
    setErrors(validate(values));
    // change state to reflect form submission
    setIsSubmitting(true);
    setIsValidated(true);
  }

  // Scroll the form to show errors
  scrollFormUp = () => {
    if (errors.length > 0) {
      const errorPosition = document.getElementsByClassName("inputError")[0].offsetTop - 250;
      window.scrollTo(0, errorPosition);
    }
  }

  // Make useForm state and methods available to components that call this hook.
  return {
    errors,
    handleChange,
    handleSubmit,
    isSubmitting,
    values
  };
}

I’ve moved the form state management and event handlers into this hook, which takes a callback submit function, the initial form values that we're fetching in context, and a validate function. The hook returns the form state (errors and values, whether it’s currently submitting) and the form event handlers so that the form component can use them like so:

const PostsForm = () => {
  const { errors, handleChange, handleSubmit, isSubmitting, values } = useForm(submitPost, post, validatePosts);

  return (
    <div>
      <label htmlFor="title">
        Post Title *
      </label>
      <input aria-describedby="title_error" className={`${errors.includes("title") && "error"}`} id="title" onChange={handleChange} placeholder="Great Post" required={true} type="text" value={values.title || ""} />
      {errors.includes("title") && <span className="inputError" id="title_error">Post title is required.</span>}

      <input id="postButton" name="commit" onMouseUp={handleSubmit} onTouchEnd={handleSubmit} type="submit" value="Submit Post" />
    </div>
  );
}

I can also move the form validation function into its own file at this point, to pass to the hook:

function validatePosts(values) {
  let errors = [];

  function validateField(field) {
    if (!(values[field] && values[field].length > 0)) {
      errors.push(field);
    }
  }

  validateField("title");

  return errors;
}

Now the file structure looks like this:

/Posts
 Posts.jsx
 PostsForm.jsx
 ValidatePosts.js
/utils
 useForm.jsx

In Conclusion & Next Steps

One benefit of this approach I didn’t foresee is that I was able to eliminate some child class components, in addition to the form class component. These had their own state management and methods, that then called methods from the form component. No more hunting down callbacks within callbacks, huzzah! All the methods that touch the form state are now consolidated in the useForm hook.

I also like that the data I’m fetching to pre-fill the form is now entirely separate (as initialValues) from the form values that the hook manipulates. It allows for separate state management, feels cleaner, and paves the way for adding more complex form functionality in the future.

I will need to make a decision about global components going forward. It’s awesome that useForm is so flexible and reusable, but what about a component like a global checkbox that needs props passed to it? I’d like to see if context can help there, but also re-evaluate when a global component makes sense — e.g. when is it small and universal enough?

In terms of the refactor, my next steps are to 1) make sure the submitted form data is acceptable to the Rails back-end, 2) add runtime type-checking, and 3) test for any browser-specific or accessibility issues that may have arisen.

Let me know if you have any questions or suggestions!

Discussion (0)