Markup-Only React Components: Here's How We Did It At Netflix

Posted by Dustin Boston on in .

Moving everything out of your page-level React components makes them easier to understand, more testable, and scalable. Here’s how we did it at Netflix.


When I was at Netflix, my team adopted a practice I’ll call markup-only components. We didn’t really have a name for this idea. It basically meant that we’d remove everything except the markup for a “container component,” as in a page-level component that manages data.

Often, in large-scale codebases, you’ll find components that have grown to several thousand lines of code. These components handle every concern without any separation. They’re untestable and extremely complex. Obviously, they need to be broken up.

We needed a reliable way to begin refactoring these components, and we wanted to make them more testable so that we could do even larger refactorings. This approach worked so well that we adopted it as a general practice for page level components.

What Are Markup-Only React Components?

Conceptually, a markup-only component is one that only contains markup. We’re not talking about Pure components; that’s something else. A markup-only component has had everything except the JSX/TSX moved into a different file. That means all data fetching, state handling, styles, business logic (gasp), and event handlers, plus anything else you can think of.

Here’s a basic example of a ZIP Code form with a Terms of Service checkbox. Unsurprisingly, refactoring slims the file down by a lot! The new component is only 58 lines instead of 181 which makes the component much easier to reason about. To achieve this we added four additional files:

Now, the refactored ZIP Code form is nice and tidy with just markup. All the logic has been moved into a hook, and a util file, and the styles and constants are separate from everything else.

Why Use Markup-Only React Components?

There are five reasons it’s worth the extra files and the refactoring process.

  1. Improved Readability: Separating the logic from the component makes it easier to understand because you can focus on the markup. The business logic has well-defined inputs and outputs. And the code is easier to read because it exists in smaller chunks across a few files.
  2. Enhanced Testability: The most significant benefit is how easy it has become to test the entire component. Especially the actual logic. This would have been much more difficult to test before.
  3. Better Reusability: Due to this quick and easy refactor, we now have both a hook and a little timer function. These can be used elsewhere in the codebase if needed.
  4. Separation of Concerns: The trifecta of styles, markup, and logic are separated into their own files. This separation of concerns ensures better code clarity, reduced complexity, and improved testability.
  5. Scalability: This little effort unlocks huge gains for large teams and codebases. It promotes modularity, enables parallel development, reduces the maintenance burden, and makes the codebase more adaptable.

How We Implemented This at Netflix

My process generally involved converting a messy giant all-in-one component into a markup-only component. First, I’d put the styles, which were usually CSS-in-JS, in another file. Then, I’d take all of the logic and move it into a hook. That would leave the TSX file with only a nice, clean render function.

For good measure, I’d usually move every function I could from inside the hook to outside the hook but still in the same file. I almost always found functions better suited for a utility file, so I’d create one and move those functions into it.

Using this approach, I could take advantage of some additional tooling. Visual Studio Code itself can handle moving chunks of code into functions, and there are plenty of Visual Studio Code extensions for refactoring React components.

Challenges and Lessons Learned

One interesting downside of this approach that I have seen at Disney Streaming is that developers can be hesitant to adopt it—Not because it’s hard, but because it’s unfamiliar. You don’t typically write a hook for a page. That said, it’s not an invalid use of hooks. In fact, one of the main reasons for hooks is their reusability.

Markup-only components could also be an over-abstraction when your component is small and simple enough to be contained within one file. It doesn’t always make sense to use this pattern. For example, we have a page at Disney that uses only one component. It would be excessive to refactor this one page into three.

A lesson that I learned is that there is a proper order to this specific refactor:

  1. Start by moving the styles into a new file and updating the logic as needed.
  2. Then, move the logic (everything except render()) into a hook.
  3. Finally, functions are moved from within to outside the hook.

Step-by-Step Guide to Adopting This Approach

So, the next time you see an out-of-control component, start by highlighting everything except the render method of your React component and then move it to another file (preferably with an extension or AI).

The classic rule for code length is about 500 lines tops or one screen, whichever is smaller. So, consider this approach if you see a component larger than that. If you’re in the 1000+ lines range, the component will benefit from a refactor.

I always use these files when I refactor: use-blah.js (the hook), blah-util.js, blah-styles.js, and blah.jsx. Or if they are in their own folder: index.jsx, hook.js, util.js, and style.js. You can’t get any more clear than that.

Conclusion

The benefits of using markup-only components are improved readability, enhanced testability, better reusability, separation of concerns, and scalability. There are lots of ilities, and we all need more ilities in our lives.

Source Code Listing

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

import {useReducer, useCallback, useState} from "react";
import "./styles.css";

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

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

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

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

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

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

  const checkboxLabelStyles = {
    ...zipCodeFormStyles,
  };

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

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

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

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

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

      default: {
        return state;
      }
    }
  }

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

  function debounce(targetFunction, delay) {
    let timeout;
    return (...arguments_) => {
      clearTimeout(timeout);
      timeout = setTimeout(() => targetFunction(...arguments_), delay);
    };
  }

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

  const onZipCodeChange = (event) => {
    debouncedZipCodeChange(event.target.value);
  };

  const onTosChange = useCallback(
    (event) => {
      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) => {
      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, validateForm, setZipCodeError],
  );

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

  function validateForm(zipCode, isChecked) {
    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-component.jsx

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

export function OutOfControlZipCodeForm() {
  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.js

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

refactored-zip-code-form-hook.jsx

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

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

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

    default: {
      return state;
    }
  }
}

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

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

  const onZipCodeChange = (event) => {
    debouncedZipCodeChange(event.target.value);
  };

  const onTosChange = useCallback(
    (event) => {
      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) => {
      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, util.validateForm, setZipCodeError],
  );

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

refactored-zip-code-form-styles.js

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

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

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

export const checkboxInputStyles = {
  marginRight: "10px",
};

export const checkboxLabelStyles = {
  ...zipCodeFormStyles,
};

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

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

refactored-zip-code-form-util.js

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

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

export function debounce(targetFunction, delay) {
  let timeout;
  return (...arguments_) => {
    clearTimeout(timeout);
    timeout = setTimeout(() => targetFunction(...arguments_), delay);
  };
}