Repository: mui/material-ui (TypeScript)
Date: 2019-03-14 00:44:28+00:00
Task type: Bug Fix

Issue:
Broken Radio if null passed
1) Take an example from https://material-ui.com/demos/selection-controls/#radio-buttons
2) Declare state =  { value: null }
3) Add componentDidMount with this.setState({ value: 'female' })
4) Run it.

No "Female" option will be chosen.
If value was set to null programmatically, then setting it to another (programmatically) not work.


Issue discussion:
@koutsenko You need to choose between the controlled and uncontrolled behavior. Check the warning in the console.
Ok, but how can i declare controlled-way that no one value is selected?
@oliviertassinari  And i have no warnings in console.
@koutsenko You can set an empty string. I can confirm that no warning is raised.
@koutsenko This problem only surfaces now because of #13915. We used to not support the uncontrolled radio mode. The best path forward I can think of is to reproduce the React warnings:
https://github.com/facebook/react/blob/c954efa70f44a44be9c33c60c57f87bea6f40a10/packages/react-dom/src/client/ReactDOMInput.js#L148
@oliviertassinari you knows better 👍 

Now after all I think that (just Javascript, no library-related rules): 
- null has meaning "no choise was performed" (uncontrolled)
- undefined means "no value was selected"
- something else - chosen radio is selected.

I agree that null must raise warning and can be issue solution.

Btw, you said, "no value" must be represented by empty string.
Why so? I just want to understand. Thanks.
Oops.. No, seems it's all wrong:

> null is an assigned value. It means nothing.
> undefined typically means a variable has been declared but not defined yet.

So, "uncontrolled" means if values are undefined, e.g. not defined in React lifecycles.
But I explicitly set it as null, and explicitly tried to set it to smthing another, and it didnt worked.

Maybe all that case is about "controlled" behaviour?

@koutsenko Here is how React handles the controlled/uncontrolled logic:
https://github.com/facebook/react/blob/c954efa70f44a44be9c33c60c57f87bea6f40a10/packages/react-dom/src/client/ReactDOMInput.js#L37-L40
But maybe we could do an exception for the radio group component, accept null as a controlled value 🤔. @eps1lon What do you think about this change?
How do we handle ToggleButtonGroup or Select?
@eps1lon I should have looked at that, great answer! The ToggleButtonGroup component can only be controlled. For the Select component, React seems to encourage `''`: 
> Warning: `value` prop on `select` should not be null. Consider using an empty string to clear the component or `undefined` for uncontrolled components.
I would go with that option then (including the warning). I think it's important that devs familiar with react don't encounter different behavior.
Does this mean that the RadioGroup should never be controlled?
> Does this mean that the RadioGroup should never be controlled?

@manonthemat It shouldn't switch between an uncontrolled and controlled state.

Solution patch:
diff --git a/docs/src/pages/demos/selection-controls/selection-controls.md b/docs/src/pages/demos/selection-controls/selection-controls.md
--- a/docs/src/pages/demos/selection-controls/selection-controls.md
+++ b/docs/src/pages/demos/selection-controls/selection-controls.md
@@ -107,3 +107,7 @@ In this case, you can apply the additional attribute (e.g. `aria-label`, `aria-l
   inputProps={{ 'aria-label': 'Checkbox A' } }
 />
 ```
+
+## Guidance
+
+- [Checkboxes vs. Radio Buttons](https://www.nngroup.com/articles/checkboxes-vs-radio-buttons/)
diff --git a/packages/material-ui/src/RadioGroup/RadioGroup.js b/packages/material-ui/src/RadioGroup/RadioGroup.js
--- a/packages/material-ui/src/RadioGroup/RadioGroup.js
+++ b/packages/material-ui/src/RadioGroup/RadioGroup.js
@@ -20,6 +20,21 @@ class RadioGroup extends React.Component {
     }
   }
 
+  componentDidUpdate() {
+    warning(
+      this.isControlled === (this.props.value != null),
+      [
+        `Material-UI: A component is changing ${
+          this.isControlled ? 'a ' : 'an un'
+        }controlled RadioGroup to be ${this.isControlled ? 'un' : ''}controlled.`,
+        'Input elements should not switch from uncontrolled to controlled (or vice versa).',
+        'Decide between using a controlled or uncontrolled RadioGroup ' +
+          'element for the lifetime of the component.',
+        'More info: https://fb.me/react-controlled-components',
+      ].join('\n'),
+    );
+  }
+
   focus = () => {
     if (!this.radios || !this.radios.length) {
       return;


Test patch:
diff --git a/packages/material-ui/src/RadioGroup/RadioGroup.test.js b/packages/material-ui/src/RadioGroup/RadioGroup.test.js
--- a/packages/material-ui/src/RadioGroup/RadioGroup.test.js
+++ b/packages/material-ui/src/RadioGroup/RadioGroup.test.js
@@ -5,14 +5,21 @@ import { createShallow, createMount } from '@material-ui/core/test-utils';
 import FormGroup from '../FormGroup';
 import Radio from '../Radio';
 import RadioGroup from './RadioGroup';
+import consoleErrorMock from 'test/utils/consoleErrorMock';
 
 describe('<RadioGroup />', () => {
   let shallow;
+  let mount;
 
   before(() => {
+    mount = createMount();
     shallow = createShallow();
   });
 
+  after(() => {
+    mount.cleanUp();
+  });
+
   it('should render a FormGroup with the radiogroup role', () => {
     const wrapper = shallow(<RadioGroup value="" />);
     assert.strictEqual(wrapper.type(), FormGroup);
@@ -200,16 +207,6 @@ describe('<RadioGroup />', () => {
   });
 
   describe('register internal radios to this.radio', () => {
-    let mount;
-
-    before(() => {
-      mount = createMount();
-    });
-
-    after(() => {
-      mount.cleanUp();
-    });
-
     it('should add a child', () => {
       const wrapper = mount(
         <RadioGroup value="">
@@ -224,4 +221,40 @@ describe('<RadioGroup />', () => {
       assert.strictEqual(wrapper.instance().radios.length, 0);
     });
   });
+
+  describe('warnings', () => {
+    beforeEach(() => {
+      consoleErrorMock.spy();
+    });
+
+    afterEach(() => {
+      consoleErrorMock.reset();
+    });
+
+    it('should warn when switching from controlled to uncontrolled', () => {
+      const wrapper = mount(
+        <RadioGroup value="foo">
+          <Radio value="foo" />
+        </RadioGroup>,
+      );
+      wrapper.setProps({ value: undefined });
+      assert.include(
+        consoleErrorMock.args()[0][0],
+        'A component is changing a controlled RadioGroup to be uncontrolled.',
+      );
+    });
+
+    it('should warn when switching between uncontrolled to controlled', () => {
+      const wrapper = mount(
+        <RadioGroup>
+          <Radio value="foo" />
+        </RadioGroup>,
+      );
+      wrapper.setProps({ value: 'foo' });
+      assert.include(
+        consoleErrorMock.args()[0][0],
+        'A component is changing an uncontrolled RadioGroup to be controlled.',
+      );
+    });
+  });
 });