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
  useEffect(() => {
    setTimeout(() => {
      onChange();
    }, 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] = useState(0);
  const [countTwoValue, setCountTwoValue] = useState(10);

  return (
    <div>
      <p>
        <Counter
          count={countOneValue}
          onChange={() => setCountOneValue(count => count + 1)}
        />
      </p>
      <p>
        <Counter
          count={countTwoValue}
          onChange={() => setCountTwoValue(count => count + 1)}
        />
      </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 probably need an uncontrolled component. So let's make it uncontrolled.

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

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

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

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

I've moved the state from the parent, down into the Counter so it manages its own state and 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, defaultCount = 0, onChange }) => {
  // local state for uncontrolled version
  const [countState, setCountState] = useState(defaultCount);

  // boolean to tell us if consumer is trying to control it or not
  const isControlled = count != null;

  // the value we will use depending on whether it is controlled
  const countValue = isControlled ? count : countState;

  // maintaining previous count for effect management (more on that later)
  const prevCountValueRef = useRef();
  const hasCountChanged = prevCountValueRef.current !== countValue;

  useEffect(() => {
    if (!hasCountChanged) return;

    setTimeout(() => {
      if (isControlled) {
        // if component is controlled, we use the prop callback
        onChange();
      } else {
        // otherwise, we set the internal state
        setCountState(countState => countState + 1);
      }
    }, 1000);
  }, [isControlled, hasCountChanged, onChange]);

  useEffect(() => {
    prevCountValueRef.current = countValue;
  });

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

This is looking a bit complicated as we now need to maintain a hasCountChanged boolean. We didn't need this before because our effect dependency was only count meaning it would only run if the count changed. However, this new effect needs to depend on the onChange callback too.

If a consumer passed their onChange handler as an inline function, it will be a new reference every render causing our effect to run every render instead of when count changes. Our new boolean allows us to ensure the effect explicitly only runs when the count changes.

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 a lot simpler. I have highlighted the important bits below that you will need to support uncontrolled/controlled versions of your components.

const Counter = ({ count, defaultCount = 0, onChange }) => {
  const [countState, setCountState] = useState(defaultCount);
  const isControlled = count != null;
  const countValue = isControlled ? count : countState;

  // ...
  if (isControlled) {
    onChange();
  } else {
    setCountState(countState => countState + 1);
  }

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

As you can see, we maintain an isControlled boolean which simply checks if the controlling prop is not null. We can then use this boolean to switch the component from managing internal state (uncontrolled), to using the props (controlled).

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] = useState(‘’);
  const [email, setEmail] = useState(‘’);
  const [message, setMessage] = useState(‘’);
  const [isSubscribed, setIsSubscribed] = useState(false);

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

    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.

import { getFormData } from 'utils/formData';

const Contact = () => {
  function handleSubmit(event) {
    const body = getFormData(event.target);

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

    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>
  );
}

// utils/formData.js
function getFormData(form) {
  const formData = new FormData(form);
  // `Object.fromEntries` requires polyfill in IE & Edge
  return Object.fromEntries(formData.entries());
}

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] = useState();
  const [comment, setComment] = useState(DEFAULT_COMMENT);

  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>
            <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)}
          />
        </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] = useState();

  return (
    <div>
      <ul>
        {['1', '2', '3'].map(postId => (
          <li>
            <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.target);
    submitComment(formData.get('comment'));
    event.preventDefault();
  }

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

I've converted the comment form into an uncontrolled component with the default text in the textarea. I've then used the key prop to re-instantiate the component when the selected post changes which 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 achieve what you need more simply 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 would be cleaner.

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