Few days ago this question was thrown on Reddit, it's a good (and recurring) one.

TLDR; Controlled components and forms without side effects are good things but it's an unstable state of aequlibrium of your code base, it's enough one uncontrolled component to make all the form un-controlled and the sad story is that soon or later you will need these stateful components.

There are some points in favour of controlled components, the most obvious one is that controlled elements depends only on the "state" (the values) of the form, everything it's easier to debug and test and, since you don't have side effects hidden within the form components, is less error prone.

That's the lesson we got from Redux and it works in theory. The practice is different.

With controlled components you have unavoidable re-renders (see React Hook Form and, if you want, LetsForm 😃 ), this could be a problem with big forms.

Also the benefit described above only works if all components are controlled, if you have just one uncontrolled component with an internal state, then you invalidate the assumption that the visual of the form only depends on the values of the form. At this point if you want to have a good test coverage, you need to create user journeys for testing (i.e. replicate all the steps to reach that particular state of the form), just testing the form based on the values of it is not enough anymore.

My opinion is this situation is quite frequent: it's a matter of time before you will need to create a stateful uncontrolled component.

First example that comes up to my mind is a floating point number control: at some point in the code you will need to parseFloat the user-entered digits into the form state, something like

const MyFloatComponent = ({ value, onChange }) => {
	return (
		<input 
			type="number" 
			value={value}
			onChange={e => onChange(parseFloat(e.target.value)}
		/>
	);
}

the problem happens when the user types "42." (suppose he wants to enter "42.42") which is a kind of intermediate state which has no representation in the form state: the onChange callback will parse it into the 42 value, put this in the form state, the control will react replacing "42." with "42". Result: the user types "42." and the "." is immediately deleted.

If you want to solve this at component level, then you need to add an internal state, if you want to solve this at form level, you will break the separation of concerns between the form and input component.

I've stumbled upon many other situations where an internal state was unavoidable.

Controlled components and forms without side effects are good things but it's an unstable state of equlibrium of your code base, it's enough one uncontrolled component to make all the form un-controlled and the sad story is that soon or later you will need these stateful components.