Four techniques you can use to write better React

Four techniques you can use to write better React
Reading Time: 11 minutes

Developing applications with React is quite easy and fun. You are in the zone, writing component after component, and your app is starting to take shape. It feels like you’ve made great progress. But sometimes, it is important to look back and analyze the quality of your code and make the life of your future self easier.

We will focus next on four techniques we can use to write better React applications. They will help you create an app that is easier to maintain, speed up your coding time, and maybe write less code, which means fewer chances of adding bugs to your beautiful app.

1. Higher Order Components

The first technique is Higher Order Component (HOC). HOCs are quite similar to the Decorator Pattern, a function that returns a function (or a component in React). 

HOCs can be used for injecting props, either processing existing props before passing them to the wrapped component or injecting totally new props. They are also a good solution for reusing common logic between components, for example exiting early if a certain condition is met, in which case you don’t even render the wrapped component. HOCs make the code in the wrapped component cleaner and they help with the separation of concerns.

Below, we have two simple components that render a list of cars and motorcycles respectively.

const Cars = () => {
  const cars = useSelect(selectCars);

  if (!cars.length) {
    return 'You don\' have cars.';
  }

  return (
    <CarsView cars={cars} />
  );
}

const Motorcycles = () => {
  const motorcycles = useSelect(selectMotorcycles);

  if (!motorcycles) {
    return 'You don\'t have motorcycles.';
  }

  return (
    <MotorcyclesView motorcycles={motorcycles} />
  );
}

As you can see, they are quite similar, and we can extract the common logic in a HOC that will render the Cars and the Motorcycles components useless.

const list = (
  selector,
  listPropName,
  emptyListMsg,
) => (WrappedComponent) => {
  return (props) => {
    const list = useSelect(selector);

    if (!list.length) {
      return emptyListMsg;
    }

    return (
      <WrappedComponent
        {...{
          ...props,
          [listPropName]: list,
        }}
      />
    );
  };
}

const Cars = list(
  selectCars,
  'cars',
  'You don\' have cars.',
)(CarsView);

const Motorcycles = list(
  selectMotorcycles,
  'motorcycles',
  'You don\'t have motorcycles.',
)(MotorcyclesView);

The advantages of this approach are that our code is DRY-er, we have fewer places where we can introduce bugs, maintaining our code is easier, and in case we want to add a new component Airplanes, our job will be much easier.

2. Hooks

React Hooks are functions that allow you to hook into React’s state and lifecycle features. They can be used only in function components. Besides the built-in hooks offered by React, like useState and useEffect, you can also write your own custom hooks where you can use other hooks and add your own logic.

They offer many of the advantages of HOCs, like props processing, reuse of common logic, and separation of concerns. Quite often, hooks have replaced HOCs. They are our favorite technique at Algotech.

In the following code section, we have two steps of a sign-up form. We used react-hook-form for handling our form.

const StepOne = () => {
  const { register, handleSubmit, setError, formState: { errors } } = useForm();

  const onSubmit = async data => {
    try {
      await service.save('step1', data);
    } catch (error) {
      setServerErrors(error, setError);
    }
  }

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <input {...register('name', { required: true })} />
      {errors.name && <span>This field is required</span>}

      <input {...register('email', { required: true })} />
      {errors.email && <span>This field is required</span>}

      <input type="submit" />
    </form>
  );
}


const StepTwo = () => {
  const { register, handleSubmit, setError, formState: { errors } } = useForm();

  const onSubmit = async data => {
    try {
      await service.save('step2', data);

      showSuccessMessage();
    } catch (error) {
      setServerErrors(error, setError);
    }
  }

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <input {...register('line1', { required: true })} />
      {errors.line1 && <span>This field is required</span>}

      <input {...register('city', { required: true })} />
      {errors.city && <span>This field is required</span>}

      <input {...register('country', { required: true })} />
      {errors.country && <span>This field is required</span>}

      <input type="submit" />
    </form>
  );
}

Both components have a submit function that does almost the same thing and this is a sign that we can refactor the code and extract similar logic in a function, or a hook.

const useFormStep = (step, successCallback) => {
  const { register, handleSubmit, setError, formState: { errors } } = useForm();

  const onSubmit = async data => {
    try {
      await service.save(step, data);

      successCallback?.();
    } catch (error) {
      setServerErrors(error, setError);
    }
  }

  return {
    register,
    handleSubmit: handleSubmit(onSubmit),
    errors,
  };
}

const StepOne = () => {
  const { register, handleSubmit, errors } = useFormStep('step1');

  return (
    <form onSubmit={handleSubmit}>
      <input {...register('name', { required: true })} />
      {errors.name && <span>This field is required</span>}

      <input {...register('email', { required: true })} />
      {errors.email && <span>This field is required</span>}

      <input type="submit" />
    </form>
  );
}


const StepTwo = () => {
  const { register, handleSubmit, errors } = useFormStep('step1', () => {
    showSuccessMessage();
  });

  return (
    <form onSubmit={handleSubmit}>
      <input {...register('line1', { required: true })} />
      {errors.line1 && <span>This field is required</span>}

      <input {...register('city', { required: true })} />
      {errors.city && <span>This field is required</span>}

      <input {...register('country', { required: true })} />
      {errors.country && <span>This field is required</span>}

      <input type="submit" />
    </form>
  );
}

After this refactoring, each of the form components uses our new hook to get access to the props it needs and the hook contains all the common logic. In the end, each function (component or hook) looks a bit cleaner and is focused on one thing.

3. Components

This might come as a surprise to see here. Everybody knows what a component is and how to use it. But there are various ways we can use components to avoid prop drilling, keep our code DRY and in the end make our lives easier.

In the code below, we extend the previous example with the sign-up form and add a parent component that renders each step.

const SignUp = (props) => {
  const { foo, bar, baz } = props;

  const fooBar = foo * bar;

  return (
    <>
      <StepOne {...{ fooBar, baz }} />
      <StepTwo {...{ fooBar, baz }} />
    </>
  )
}


const StepOne = ({ fooBar, baz }) => {
  const { register, handleSubmit, errors } = useFormStep('step1');

  return (
    <form onSubmit={handleSubmit}>
      <input
        {...{
          ...register('name', { required: true }),
          disabled: fooBar && baz,
        }}
      />
      {errors.name && <span>This field is required</span>}

      <input
        {...{
          ...register('email', { required: true }),
          displabed: fooBar && baz,
        }}
      />
      {errors.email && <span>This field is required</span>}

      <input type="submit" />
    </form>
  );
}

One of the issues is that the top component passes down props that are not necessary to the intermediary components but have to go through there, an issue known as prop drilling. Another issue is the way we render each field and the error message. Let’s refactor this code a bit.

const SignUpContext = React.createContext(null);

const SignUp = (props) => {
  const { foo, bar, baz } = props;

  const fooBar = foo * bar;

  return (
    <SignUpContext.Provider value={{ fooBar, baz }}>
      <StepOne {...{ fooBar, baz }} />
      <StepTwo {...{ fooBar, baz }} />
    </SignUpContext.Provider>
  )
}

const Field = ({ name, register, errors, disabled }) => {
  return (
    <>
      <input
        {...{
          ...register(name, { required: true }),
          disabled,
        }} />
      {errors[name] && <span>This field is required</span>}
    </>
  );
}

const SignUpField = ({ name, register, errors }) => {
  const { fooBar, baz } = useContext(SignUpContext);

  return (
    <Field {...{ name, register, errors, disabled: fooBar && baz }} />
  );
}

const StepOne = () => {
  const { register, handleSubmit, errors } = useFormStep('step1');

  return (
    <form onSubmit={handleSubmit}>
      <SignUpField {...{ name: 'name', register, errors }} />

      <SignUpField {...{ name: 'email', register, errors }} />

      <input type="submit" />
    </form>
  );
}

After this refactoring, we solved the issue of prop drilling using React Context and a component that reads from the context and renders the input. The input and the error message have now their own “home” in the Field component and can easily be used for other forms.

An important detail to notice here is the fact that we use two components for the input field. We could have grouped the SignUpField component together with the Field component, but then we would have lost the possibility to reuse the Field component on other forms by coupling it with the sign-up form.

4. Function wrappers

Function wrappers allow you to call another function (the wrapped function) with some functionality added before or after the wrapped function call. One perfect use case for function wrappers is the consistent handling of errors when a form is submitted, but there are many more things you can accomplish with them, like logging or profiling.

In the following example code, we have two submit functions that share only the success and failure notifications for the user. 

const submitFoo = (data) => {
  try {
    const dataToSave = doSomethingWithFormData(data);
    const response = await fooService.save(dataToSave);

    notifySuccess(response);
  } catch (error) {
    notifyFailure(error);
  }
}

const submitBar = (data) => {
  try {
    const response = await barService.save(data);
    await notifyOtherService(response);

    notifySuccess(response);
  } catch (error) {
    notifyFailure(error);
  }
}

What happens for the actual submission is quite different in each form, but we can make sure we will handle consistently the success and the error notifications every time by using a wrapper.

const submitWrapper = (func) => (data) => {
  try {
    const response = await func(data);

    notifySuccess(response);
  } catch (error) {
    notifyFailure(error);
  }
}

const submitFoo = (data) => {
  const submit = submitWrapper((data) => {
    const dataToSave = doSomethingWithFormData(data);

    return fooService.save(dataToSave);
  });

  return submit(data);
}

const submitBar = (data) => {
  const submit = submitWrapper(async (data) => {
    const response = await barService.save(data);
    await notifyOtherService(response);

    return response;
  });

  return submit(data);
}

Our wrapper handles the error and the success notification now, letting us focus on what is important and custom for each form. Again, we have obtained DRY-er code and a better experience for the developer and maybe for the user.

When not to reuse code

All the methods discussed so far are a way of reusing code and keeping it DRY. While this can help us greatly and speed up our development time, it can also do the opposite if we try to reuse as much code as possible all the time without analyzing whether it is worth doing it. At Algotech, we have a few guiding principles that help us decide when reusing code is a good idea.

  1. Never create smart abstractions and prepare for reusability before you have at least two use cases for your smart function or class. The reality is that we rarely know in advance how a project will evolve and what our needs will be in advance. That’s why it is better to wait until a real opportunity for reusability appears and then we can refactor our code.
  2. Analyze whether the two components that look so similar are, in fact, going to evolve in the same way and for the same reasons. It is possible that right now, they look identical by pure coincidence and the next set of changes will prove that they are totally different and unrelated.
  3. Avoid refactoring when the end result is too abstract and hard to understand or using that fancy reusable piece of code implies a lot of customizations in the original code. These are hints that maybe those components are not that similar and making the code reusable will just make our lives harder.

Final thoughts

All the methods I mentioned in this article are nothing new, and simply knowing that they exist is not enough. Our brain is lazy. It takes effort, but we must always keep an eye out for what we can do better. It will improve the development experience now and in the future (plus, it’s what makes development fun, at least for me). But, at the same time, we must choose the right time for applying these improvements and not overdo it. 

We transform challenges into digital experiences

Get in touch to let us know what you’re looking for. Our policy includes 14 days risk-free!

Free project consultation