Markup-Only React Components

Posted by Dustin Boston on in .

It’s common in large-scale codebases to find 1,000-line components. These components handle every concern without any separation. They’re untestable and complex. One solution is to refactor stateful components to contain only markup. Then, extract all logic into dedicated hooks and utility files. This practice makes components more testable and simplifies significant code changes.

The “Markup-Only” Pattern

A “markup-only” component is a stateful component where the file contains only JSX. All related logic gets moved into other files.

  • A custom hook manages state, data fetching, and event handlers.
  • Utility files contain pure functions, such as validation or data formatting.
  • Style files contain all styling objects or declarations (assuming CSS-in-JS).
  • Constant files store static values.

Here’s a basic example of a ZIP code form. We start with 194 lines of code. Refactoring slims the file down to 58 lines! The resulting component file is clean and easy to understand at a glance.

Why Markup Only?

This pattern has five big advantages:

  1. Improved Readability: Separating the logic from the component makes it easier to understand. The business logic has well-defined inputs and outputs. It’s easier to understand smaller chunks.
  2. Easier Testing: The most significant benefit is the ease of testing. By extracting logic out of the component and into plain functions, we can use standard unit tests.
  3. Better Reusability: Due to this quick and easy refactor, we now have both a hook and some little functions. We can use these elsewhere in the codebase.
  4. Scalability: This little effort unlocks huge gains for teams and codebases. It promotes modularity, enables parallel development, and reduces the maintenance burden. Plus, it makes the codebase more adaptable.
  5. Separation of Concerns: Styles, markup, and logic are in separate files. This separation of concerns ensures better code clarity, reduced complexity, and improved testability.

Refactoring Guide

Here’s how I turn an all-in-one component into one that uses only markup.

  1. Start by moving the styles into a new file.
  2. Then, move everything except the render() function into a hook.
  3. Next, move functions from within the hook to outside the hook.
  4. Identify reusable functions and move them into a utility file.
  5. Finally, move any static values into a constants file.

Considerations

This pattern is not a universal rule.

  • Adoption: Developers can be hesitant to adopt an unfamiliar pattern. Remember, it’s a tool for managing complexity, not a requirement.
  • Overhead: For simple components, creating many files is unnecessary overhead. Consider this approach for long files (more than 500 lines).

Using a component with only markup has several benefits. It improves readability, enhances testability, and boosts reusability. It separates concerns and supports scalability. Just look at all those -ilities.

Source Code Listing

out-of-control-zip-code-form.tsx

import {useReducer, useCallback, useState} from "react";

const SET_ZIP_CODE = "SET_ZIP_CODE";
const SET_TOS = "SET_TOS";

interface FormState {
  zipCode: string;
  tos: boolean;
  isValid: boolean;
}

interface FormAction {
  type: typeof SET_ZIP_CODE | typeof SET_TOS;
  payload: string | boolean;
}

export function ZipCodeForm() {
  const initialState: FormState = {zipCode: "", tos: false, isValid: false};

  const zipCodeFormStyles: React.CSSProperties = {
    fontFamily: "sans-serif",
    fontSize: "16px",
  };

  const labelStyles: React.CSSProperties = {
    ...zipCodeFormStyles,
    display: "block",
    fontWeight: "bold",
    marginBottom: "5px",
    marginTop: "10px",
  };

  const zipCodeInputStyles: React.CSSProperties = {
    ...zipCodeFormStyles,
    background: "white",
    border: "1px solid silver",
    borderRadius: "5px",
    color: "black",
    padding: "10px",
    marginRight: "10px",
    marginBottom: "10px",
  };

  const checkboxInputStyles: React.CSSProperties = {
    marginRight: "10px",
  };

  const checkboxLabelStyles: React.CSSProperties = {
    ...zipCodeFormStyles,
  };

  const submitButtonStyles: React.CSSProperties = {
    ...zipCodeFormStyles,
    padding: "4px",
  };

  const errorStyles: React.CSSProperties = {
    ...zipCodeFormStyles,
    color: "red",
  };

  const [zipCodeError, setZipCodeError] = useState<string>("");
  const [tosError, setTosError] = useState<string>("");

  function formReducer(state: FormState, action: FormAction): FormState {
    switch (action.type) {
      case SET_ZIP_CODE: {
        return {
          ...state,
          zipCode: action.payload as string,
          isValid: validateForm(action.payload as string, state.tos),
        };
      }

      case SET_TOS: {
        return {
          ...state,
          tos: action.payload as boolean,
          isValid: validateForm(state.zipCode, action.payload as boolean),
        };
      }

      default: {
        return state;
      }
    }
  }

  const [formState, dispatch] = useReducer(formReducer, initialState);

  function debounce<T extends (...args: any[]) => void>(
    targetFunction: T,
    delay: number,
  ): (...args: Parameters<T>) => void {
    let timeout: NodeJS.Timeout | undefined;
    return (...args: Parameters<T>) => {
      clearTimeout(timeout);
      timeout = setTimeout(() => targetFunction(...args), delay);
    };
  }

  const debouncedZipCodeChange = useCallback(
    debounce((value: string) => {
      dispatch({type: SET_ZIP_CODE, payload: value});
    }, 300),
    [dispatch],
  );

  const onZipCodeChange = (event: React.ChangeEvent<HTMLInputElement>) => {
    debouncedZipCodeChange(event.target.value);
  };

  const onTosChange = useCallback(
    (event: React.ChangeEvent<HTMLInputElement>) => {
      const isChecked = event.target.checked;
      dispatch({type: SET_TOS, payload: isChecked});
      setTosError(isChecked ? "" : "You must agree to the terms.");
    },
    [dispatch, setTosError],
  );

  const onFormSubmit = useCallback(
    (event: React.FormEvent<HTMLFormElement>) => {
      event.preventDefault();
      if (!validateForm(formState.zipCode, formState.tos)) {
        setZipCodeError(
          "Please enter a valid ZIP Code and agree to the terms.",
        );
        return;
      }

      setZipCodeError("");
      console.log("Form submitted successfully");
    },
    [formState],
  );

  function validateZipCode(zipCode: string): boolean {
    return /\d{5}/.test(zipCode.trim());
  }

  function validateForm(zipCode: string, isChecked: boolean): boolean {
    return validateZipCode(zipCode) && isChecked;
  }

  return (
    <form onSubmit={onFormSubmit}>
      <div>
        {zipCodeError && <div style={errorStyles}>{zipCodeError}</div>}
        <label htmlFor="zipCode" style={labelStyles}>
          ZIP Code
        </label>{" "}
        <input
          id="zipCode"
          name="zipCode"
          style={zipCodeInputStyles}
          type="text"
          onChange={onZipCodeChange}
          placeholder="Enter ZIP Code"
          maxLength={5}
          minLength={5}
          required
          aria-label="Enter your ZIP Code"
        />
      </div>
      <div>
        {tosError && <div style={errorStyles}>{tosError}</div>}
        <label htmlFor="tos" style={labelStyles}>
          <input
            id="tos"
            name="tos"
            style={checkboxInputStyles}
            type="checkbox"
            onChange={onTosChange}
          />
          <span style={checkboxLabelStyles}>I agree</span>
        </label>
      </div>
      <div>
        <button
          style={submitButtonStyles}
          type="submit"
          disabled={!formState.isValid}
        >
          Submit
        </button>
      </div>
    </form>
  );
}

export default function App() {
  return <ZipCodeForm />;
}

refactored-zip-code-form.tsx

import * as styles from "./refactored-zip-code-form-styles.tsx";
import {useZipCodeFormHook} from "./refactored-zip-code-form-hook.tsx";

export function RefactoredZipCodeForm() {
  const {
    formState,
    onFormSubmit,
    onTosChange,
    onZipCodeChange,
    tosError,
    zipCodeError,
  } = useZipCodeFormHook();

  return (
    <form onSubmit={onFormSubmit}>
      <div>
        {zipCodeError && <div style={styles.errorStyles}>{zipCodeError}</div>}
        <label htmlFor="zipCode" style={styles.labelStyles}>
          ZIP Code
        </label>{" "}
        <input
          id="zipCode"
          name="zipCode"
          style={styles.zipCodeInputStyles}
          type="text"
          onChange={onZipCodeChange}
          placeholder="Enter ZIP Code"
          maxLength={5}
          minLength={5}
          required
          aria-label="Enter your ZIP Code"
        />
      </div>
      <div>
        {tosError && <div style={styles.errorStyles}>{tosError}</div>}
        <label htmlFor="tos" style={styles.labelStyles}>
          <input
            id="tos"
            name="tos"
            style={styles.checkboxInputStyles}
            type="checkbox"
            onChange={onTosChange}
          />
          <span style={styles.checkboxLabelStyles}>I agree</span>
        </label>
      </div>
      <div>
        <button
          style={styles.submitButtonStyles}
          type="submit"
          disabled={!formState.isValid}
        >
          Submit
        </button>
      </div>
    </form>
  );
}

refactored-zip-code-form-constants.ts

export const SET_ZIP_CODE = "SET_ZIP_CODE";
export const SET_TOS = "SET_TOS";

refactored-zip-code-form-hook.tsx

import {useReducer, useCallback, useState, useMemo} from "react";
import * as util from "./refactored-zip-code-form-utils.ts";
import * as constants from "./refactored-zip-code-form-constants.ts";

interface FormState {
  zipCode: string;
  tos: boolean;
  isValid: boolean;
}

interface FormAction {
  type: typeof constants.SET_ZIP_CODE | typeof constants.SET_TOS;
  payload: string | boolean;
}

function formReducer(state: FormState, action: FormAction): FormState {
  switch (action.type) {
    case constants.SET_ZIP_CODE: {
      return {
        ...state,
        zipCode: action.payload as string,
        isValid: util.validateForm(action.payload as string, state.tos),
      };
    }

    case constants.SET_TOS: {
      return {
        ...state,
        tos: action.payload as boolean,
        isValid: util.validateForm(state.zipCode, action.payload as boolean),
      };
    }

    default: {
      return state;
    }
  }
}

export const useZipCodeFormHook = () => {
  const initialState: FormState = {zipCode: "", tos: false, isValid: false};
  const [zipCodeError, setZipCodeError] = useState<string>("");
  const [tosError, setTosError] = useState<string>("");
  const [formState, dispatch] = useReducer(formReducer, initialState);

  const debouncedZipCodeChange = useCallback(
    util.debounce((value: string) => {
      dispatch({type: constants.SET_ZIP_CODE, payload: value});
    }, 300),
    [dispatch],
  );

  const onZipCodeChange = (event: React.ChangeEvent<HTMLInputElement>) => {
    debouncedZipCodeChange(event.target.value);
  };

  const onTosChange = useCallback(
    (event: React.ChangeEvent<HTMLInputElement>) => {
      const isChecked = event.target.checked;
      dispatch({type: constants.SET_TOS, payload: isChecked});
      setTosError(isChecked ? "" : "You must agree to the terms.");
    },
    [dispatch, setTosError],
  );

  const onFormSubmit = useCallback(
    (event: React.FormEvent<HTMLFormElement>) => {
      event.preventDefault();
      if (!util.validateForm(formState.zipCode, formState.tos)) {
        setZipCodeError(
          "Please enter a valid ZIP Code and agree to the terms.",
        );
        return;
      }

      setZipCodeError("");
      console.log("Form submitted successfully");
    },
    [formState],
  );

  return useMemo(
    () => ({
      formState,
      onTosChange,
      onZipCodeChange,
      tosError,
      zipCodeError,
      onFormSubmit,
    }),
    [
      formState,
      onTosChange,
      onZipCodeChange,
      tosError,
      zipCodeError,
      onFormSubmit,
    ],
  );
};

refactored-zip-code-form-utils.ts

export function validateZipCode(zipCode: string): boolean {
  return /\d{5}/.test(zipCode.trim());
}

export function validateForm(zipCode: string, isChecked: boolean): boolean {
  return validateZipCode(zipCode) && isChecked;
}

export function debounce<T extends (...args: any[]) => void>(
  targetFunction: T,
  delay: number,
): (...args: Parameters<T>) => void {
  let timeout: NodeJS.Timeout | undefined;
  return (...arguments_: Parameters<T>) => {
    clearTimeout(timeout);
    timeout = setTimeout(() => targetFunction(...arguments_), delay);
  };
}