Are You a Component Control Freak?

Published: 29 August 2019

It’s tempting to always control the components we implement. There are many tutorials that teach us about lifting state including React’s own intro tutorial so it’s no wonder we often reach for this approach when working with React.

A controlled component accepts a prop with a corresponding onPropChange prop and its internal representation of this value is controlled entirely by these two props. For example, a text input has value and onChange props. When provided, the input becomes controlled and it is up to us to pass an updated value when onChange occurs. If we specified these props but didn't update the value, the input would not reflect what the user typed.

This pattern works well but things can sometimes be simplified by using an alternate approach.

Uncontrolled components

Uncontrolled components are touched on briefly in the docs and only with a focus on inputs and refs. While this is a good read, it doesn’t particularly help us realise the full potential of the pattern. For example, it is possible to build our own components that can be uncontrolled. So, what makes a component uncontrolled?

Uncontrolled components are components that manage their own internal state to accomplish their concern. They often allow the consumer to specify the initial state and this is usually declared by a prop prefixed with default.

The docs show examples of uncontrolled inputs using defaultValue or defaultChecked props. With an uncontrolled input, we can pass a defaultValue and we will see this value inside the input initially with subsequent typing reflected without us needing to manage any lifted state.

Building our own

The following is an example of how dumb components are often built. We give them some props and some callbacks and, voilà, we have a dumb Counter component.

const Counter = ({ count = 0, onChange }) => {
  // Effect with `count` dependency so effect runs every time `count` changes
  React.useEffect(() => {
    setTimeout(() => onChange(count + 1), 1000);
  }, [count]);

  return <span>{count}</span>;
};

Unfortunately, this will render the number zero but we won't see much counting going on here. So to fix that, we add some state to our parent component.

const App = () => {
  const [countOneValue, setCountOneValue] = React.useState(0);
  const [countTwoValue, setCountTwoValue] = React.useState(10);

  return (
    <div>
      <p>
        <Counter count={countOneValue} onChange={setCountOneValue} />
      </p>
      <p>
        <Counter count={countTwoValue} onChange={setCountTwoValue} />
      </p>
    </div>
  );
};

This gives us two counters that work as expected but they're controlled. Currently, anyone that wants to use the Counter component will have to implement similar state to make it count.

When you find yourself in this situation, repeating the same lifted state, you will probably benefit from making it an uncontrolled component. To do this, we just move the state from the parent and down into the Counter so it manages its own state.

const App = () => (
  <div>
    <p>
      <Counter />
    </p>
    <p>
      <Counter defaultCount={10} />
    </p>
  </div>
);

const Counter = ({ defaultCount = 0 }) => {
  const [count, setCount] = React.useState(defaultCount);

  React.useEffect(() => {
    setTimeout(() => setCount(prevCount => prevCount + 1), 1000);
  }, [count]);

  return <span>{countValue}</span>;
};

We now have a very simple uncontrolled component that behaves as expected all by itself. The consumer no longer needs to control it for it to behave as expected and it has cleared up some repetition and state in our parent component as a result.

Allowing uncontrolled components to be controlled

As we all know, the client will eventually come up with an obscure requirement and ask that some implementations increase by 10. No problem, we can add a step prop, but then suddenly they would also like it to increment by multiples of 2. The consumer is beginning to need more control over the way the counter counts. Fortunately, we can give them the option of that control.

Components can support both controlled and uncontrolled implementations by checking if the consumer has passed the controlled version of the prop, in this case, a count prop. If they have, we can make the internals switch to using its props to control what's rendered.

const Counter = ({
  count: countProp,
  defaultCount = 0,
  onChange = () => {},
}) => {
  // local state for uncontrolled version
  const [countState, setCountState] = React.useState(defaultCount);

  // whether consumer is trying to control it or not. we use a ref because
  // components should not switch between controlled/uncontrolled at runtime
  const isControlled = React.useRef(countProp !== undefined).current;

  // the count value we render depending on whether it is controlled
  const count = isControlled ? countProp : countState;

  // maintaining change callback in a ref (more on that later)
  const handleChangeRef = React.useRef(onChange);
  React.useLayoutEffect(() => {
    handleChangeRef.current = onChange;
  });

  React.useEffect(() => {
    const handleChange = handleChangeRef.current;

    setTimeout(() => {
      if (isControlled) {
        handleChange(count + 1);
      } else {
        setCountState((prevCount = 0) => {
          const nextCount = prevCount + 1;
          handleChange(nextCount);
          return nextCount;
        });
      }
    }, 1000);
  }, [isControlled, count]);

  return <span>{count}</span>;
};

This is looking slightly complicated as we need to maintain a handleChangeRef. We didn't need this before because our effect dependency was only count so it would only run when the count changed. However, this new effect needs to depend on the onChange callback too and if we add that as a dependency, the hook could run every render if someone passes the handler as an inline function.

This approach gives us an updated reference to the handler without adding it as an effect dependency so ensures the effect only runs when count changes (update: React team have since announced an RFC for a useEvent hook for this use-case).

More often than not, your onChange equivalent would be called in response to a user interaction so there would be no need for this effect management, and things would look less complex. I have highlighted the important bits below that you will need to support uncontrolled/controlled versions of your components.

const Counter = ({
  count: countProp,
  defaultCount = 0,
  onChange = () => {},
}) => {
  const [countState, setCountState] = React.useState(defaultCount);
  const isControlled = React.useRef(countProp !== undefined).current;
  const count = isControlled ? countProp : countState;

  // ...
  if (isControlled) {
    onChange(count + 1);
  } else {
    // if component is uncontrolled, we set the internal state
    setCountState((prevCount = 0) => {
      const nextCount = prevCount + 1;
      onChange(nextCount);
      return nextCount;
    });
  }

  // ...
  return <span>{count}</span>;
};

We maintain an isControlled boolean which checks if the controlling prop is not undefined. We then use the isControlled boolean to switch the component from managing internal state (uncontrolled), to using the props (controlled).

We've essentially created the equivalent of form fields in React here. A component that can be both controlled or uncontrolled.

Uncontrolled forms

The following is an example of a common approach to forms that I've seen on different projects:

const Contact = () => {
  const [name, setName] = React.useState(‘’);
  const [email, setEmail] = React.useState(‘’);
  const [message, setMessage] = React.useState(‘’);
  const [isSubscribed, setIsSubscribed] = React.useState(false);

  function handleSubmit(event) {
    fetch(/contact’, {
      mode:POST,
      body: JSON.stringify({ name, email, message, isSubscribed }),
    });

    event.preventDefault();
  }

  return (
    <form onSubmit={handleSubmit}>
      <label>
        Name
        <input type=“text” value={name} onChange={event => setName(event.target.value)} />
      </label>
      <label>
        Email
        <input type=“email” value={email} onChange={event => setEmail(event.target.value)} />
      </label>
      <label>
        Message
        <textarea value={message} onChange={event => setMessage(event.target.value)} />
      </label>
      <label>
        <input type=“checkbox” checked={isSubscribed} onChange={event => setIsSubscribed(event.checked)} />
        Please check this box if you would like to subscribe to our newsletter
      </label>
    </form>
  );
}

We are maintaining our own state to store input values so that we can later create a JSON string from the form values. Every time a user updates a field, the entire form will re-render. This isn't a huge problem in this particular example but forms can end up more complex than this.

Forms are often a perfect candidate for remaining uncontrolled because they are inherently a data store. Inputs can be uncontrolled and thus, will maintain what the user enters without the need for control. If we name our inputs, we can then use the FormData API to get the form values when they're needed.

const Contact = () => {
  function handleSubmit(event) {
    const formData = new FormData(event.currentTarget);
    const body = Object.fromEntries(formData.entries());

    fetch(/contact’, { mode:POST, body: JSON.stringify(body) });
    event.preventDefault();
  }

  return (
    <form onSubmit={handleSubmit}>
      <label>
        Name
        <input type=“text” name="name" />
      </label>
      <label>
        Email
        <input type=“email” name="email" />
      </label>
      <label>
        Message
        <textarea name="message" />
      </label>
      <label>
        <input type=“checkbox” name="isSubscribed" />
        Please check this box if you would like to subscribe to our newsletter
      </label>
    </form>
  );
}

This isn't massively reduced code, but we've simplified our Contact component by getting rid of the unnecessary state management and prevented the entire form from re-rendering on every keystroke.

Since this is such a common requirement, I often create a reusable Form component that wraps something similar to the above util and will give me an object literal representation of its fields when its onSubmit and onChange events fire.

Uncontrolled paired with the key prop

Another powerful duo is to combine uncontrolled components with the key prop from React.

We've all added logic to our components to reset state under certain conditions at one stage or another. The following example resets a comment form when the user selects a different post from a radio list:

const DEFAULT_COMMENT = 'Sounds decent';

const App = () => {
  const [selectedPostId, setSelectedPostId] = React.useState();
  const [comment, setComment] = React.useState(DEFAULT_COMMENT);

  React.useEffect(() => {
    // reset state back to original
    setComment(DEFAULT_COMMENT);
  }, [selectedPostId]);

  function handleSubmitComment(event) {
    submitComment(comment);
    event.preventDefault();
  }

  return (
    <div>
      <ul>
        {['1', '2', '3'].map(postId => (
          <li key={postId}>
            <input
              type="radio"
              value={postId}
              onChange={event => setSelectedPostId(event.target.value)}
              checked={selectedPostId === postId}
            />{' '}
            Post {postId}
          </li>
        ))}
      </ul>
      {selectedPostId && (
        <form onSubmit={handleSubmitComment}>
          <h2>Comment on post {selectedPostId}</h2>
          <textarea
            value={comment}
            onChange={event => setComment(event.target.value)}
          />
          <br />
          <button>comment</button>
        </form>
      )}
    </div>
  );
};

You can try this example. Try adding text to the comment box and then when you select a different post, you will see it reset the text back to the default. If you catch yourself writing code to reset state like this, the key prop can help simplify things.

When the key prop changes, it re-instantiates your component. We can take advantage of this to return our comment form back to its original state.

const App = () => {
  const [selectedPostId, setSelectedPostId] = React.useState();

  return (
    <div>
      <ul>
        {['1', '2', '3'].map(postId => (
          <li key={postId}>
            <input
              type="radio"
              value={postId}
              onChange={event => setSelectedPostId(event.target.value)}
              checked={selectedPostId === postId}
            />{' '}
            Post {postId}
          </li>
        ))}
      </ul>
      {selectedPostId && <Comment key={selectedPostId} id={selectedPostId} />}
    </div>
  );
};

const Comment = ({ id }) => {
  function handleSubmitComment(event) {
    const formData = new FormData(event.currentTarget);
    submitComment(formData.get('comment'));
    event.preventDefault();
  }

  return (
    <form onSubmit={handleSubmitComment}>
      <h2>Comment on post {id}</h2>
      <textarea name="comment" defaultValue="Sounds decent" />
      <br />
      <button>comment</button>
    </form>
  );
};

I've made the textarea uncontrolled by passing the initial value as the defaultValue prop and then used the key prop to re-instantiate the Comment component when the selected post changes. This will reset the text in the textarea. You can see it in action for comparison.

Notice how I no longer need an effect, or an abstracted variable for the default state value, or any comment state (resulting in reduced rerenders of our App). Everything is self-contained and explicit. When I first stumbled across this, I thought I was perhaps misusing the key prop but it turns out that the React team suggest it themselves.

You can do some pretty cool things with this, like this optimistic update craziness I was playing with once upon a time. It dawned on me, uncontrolled components are "optimistic components" because they immediately reflect an attempted change in state. Therefore, instead of controlling a component, maintaining some "previous value" state and resetting it to that value when a request fails, we can use an uncontrolled component with a key that reinstantiates the component if the request fails. Cool huh?

Conclusion

When you find yourself lifting-state, think for a moment. Do I really need this state? Could you reduce complexity if you use an uncontrolled API? Additionally, if you're trying to get a component into a state that it started out at, perhaps an uncontrolled/key pairing could help for more lightweight components.

I've personally managed to reduce the complexity in my applications by thinking this way. I hope you find it useful too 🙂

© 2024