Skip to content

Commit

Permalink
ReactLink: two-way binding for React
Browse files Browse the repository at this point in the history
This introduces `ReactLink` which is a super lightweight way to do two-way binding for React.

If you want to use a controlled form input today, it's a lot of lines of code:

http://jsfiddle.net/T3z3v/

Look how many times `name` is repeated in there. And you have to remember to wire up event handles and pass the right state and
right event handler for *each* form field. It's really annoying.

With ReactLink, you can "link" a form value to a state field. It's just some simple sugar around the value prop/onChange
convention:

https://gist.github.com/petehunt/6689857

Ah, much nicer! And requires very little core changes or extra bytes. `ReactLink` just wraps the current value and "request
change" handler into a little object and provides some sugar to create some from composite component state.
  • Loading branch information
petehunt authored and zpao committed Sep 24, 2013
1 parent 458836a commit fc73bf0
Show file tree
Hide file tree
Showing 10 changed files with 343 additions and 17 deletions.
46 changes: 46 additions & 0 deletions src/addons/link/LinkedStateMixin.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
/**
* Copyright 2013 Facebook, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* @providesModule LinkedStateMixin
* @typechecks static-only
*/

"use strict";

var ReactLink = require('ReactLink');
var ReactStateSetters = require('ReactStateSetters');

/**
* A simple mixin around ReactLink.forState().
*/
var LinkedStateMixin = {
/**
* Create a ReactLink that's linked to part of this component's state. The
* ReactLink will have the current value of this.state[key] and will call
* setState() when a change is requested.
*
* @param {string} key state key to update. Note: you may want to use keyOf()
* if you're using Google Closure Compiler advanced mode.
* @return {ReactLink} ReactLink instance linking to the state.
*/
linkState: function(key) {
return new ReactLink(
this.state[key],
ReactStateSetters.createStateKeySetter(this, key)
);
}
};

module.exports = LinkedStateMixin;
54 changes: 54 additions & 0 deletions src/addons/link/ReactLink.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
/**
* Copyright 2013 Facebook, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* @providesModule ReactLink
* @typechecks static-only
*/

"use strict";

/**
* ReactLink encapsulates a common pattern in which a component wants to modify
* a prop received from its parent. ReactLink allows the parent to pass down a
* value coupled with a callback that, when invoked, expresses an intent to
* modify that value. For example:
*
* React.createClass({
* getInitialState: function() {
* return {value: ''};
* },
* render: function() {
* var valueLink = new ReactLink(this.state.value, this._handleValueChange);
* return <input valueLink={valueLink} />;
* },
* this._handleValueChange: function(newValue) {
* this.setState({value: newValue});
* }
* });
*
* We have provided some sugary mixins to make the creation and
* consumption of ReactLink easier; see LinkedValueMixin and LinkedStateMixin.
*/

/**
* @param {*} value current value of the link
* @param {function} requestChange callback to request a change
*/
function ReactLink(value, requestChange) {
this.value = value;
this.requestChange = requestChange;
}

module.exports = ReactLink;
56 changes: 56 additions & 0 deletions src/addons/link/__tests__/LinkedStateMixin-test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
/**
* Copyright 2013 Facebook, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* @jsx React.DOM
* @emails react-core
*/

"use strict";

/*jshint evil:true */

describe('LinkedStateMixin', function() {
var LinkedStateMixin;
var React;
var ReactLink;

beforeEach(function() {
LinkedStateMixin = require('LinkedStateMixin');
React = require('React');
ReactLink = require('ReactLink');
});

it('should create a ReactLink for state', function() {
var Component = React.createClass({
mixins: [LinkedStateMixin],

getInitialState: function() {
return {value: 'initial value'};
},

render: function() {
return <span>value is {this.state.value}</span>;
}
});
var container = document.createElement('div');
var component = React.renderComponent(<Component />, container);
var link = component.linkState('value');
expect(component.state.value).toBe('initial value');
expect(link.value).toBe('initial value');
link.requestChange('new value');
expect(component.state.value).toBe('new value');
expect(component.linkState('value').value).toBe('new value');
});
});
68 changes: 68 additions & 0 deletions src/dom/components/LinkedValueMixin.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
/**
* Copyright 2013 Facebook, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* @providesModule LinkedValueMixin
* @typechecks static-only
*/

"use strict";

var invariant = require('invariant');

/**
* Provide a linked `value` attribute for controlled forms. You should not use
* this outside of the ReactDOM controlled form components.
*/
var LinkedValueMixin = {
_assertLink: function() {
invariant(
this.props.value == null && this.props.onChange == null,
'Cannot provide a valueLink and a value or onChange event. If you ' +
'want to use value or onChange, you probably don\'t want to use ' +
'valueLink'
);
},

/**
* @return {*} current value of the input either from value prop or link.
*/
getValue: function() {
if (this.props.valueLink) {
this._assertLink();
return this.props.valueLink.value;
}
return this.props.value;
},

/**
* @return {function} change callback either from onChange prop or link.
*/
getOnChange: function() {
if (this.props.valueLink) {
this._assertLink();
return this._handleLinkedValueChange;
}
return this.props.onChange;
},

/**
* @param {SyntheticEvent} e change event to handle
*/
_handleLinkedValueChange: function(e) {
this.props.valueLink.requestChange(e.target.value);
}
};

module.exports = LinkedValueMixin;
18 changes: 12 additions & 6 deletions src/dom/components/ReactDOMInput.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
"use strict";

var DOMPropertyOperations = require('DOMPropertyOperations');
var LinkedValueMixin = require('LinkedValueMixin');
var ReactCompositeComponent = require('ReactCompositeComponent');
var ReactDOM = require('ReactDOM');
var ReactMount = require('ReactMount');
Expand Down Expand Up @@ -48,6 +49,7 @@ var instancesByReactID = {};
* @see http://www.w3.org/TR/2012/WD-html5-20121025/the-input-element.html
*/
var ReactDOMInput = ReactCompositeComponent.createClass({
mixins: [LinkedValueMixin],

getInitialState: function() {
var defaultValue = this.props.defaultValue;
Expand All @@ -71,8 +73,9 @@ var ReactDOMInput = ReactCompositeComponent.createClass({
props.checked =
this.props.checked != null ? this.props.checked : this.state.checked;

props.value = this.props.value != null && this.props.value !== false
? '' + this.props.value
var value = this.getValue();
props.value = value != null && value !== false
? '' + value
: this.state.value;

props.onChange = this._handleChange;
Expand All @@ -99,22 +102,25 @@ var ReactDOMInput = ReactCompositeComponent.createClass({
this.props.checked || false
);
}
if (this.props.value != null) {

var value = this.getValue();
if (value != null) {
// Cast `this.props.value` to a string so falsey values that cast to
// truthy strings are not ignored.
DOMPropertyOperations.setValueForProperty(
rootNode,
'value',
this.props.value !== false ? '' + this.props.value : ''
value !== false ? '' + value : ''
);
}
},

_handleChange: function(event) {
var returnValue;
if (this.props.onChange) {
var onChange = this.getOnChange();
if (onChange) {
this._isChanging = true;
returnValue = this.props.onChange(event);
returnValue = onChange(event);
this._isChanging = false;
}
this.setState({
Expand Down
10 changes: 7 additions & 3 deletions src/dom/components/ReactDOMSelect.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@

"use strict";

var LinkedValueMixin = require('LinkedValueMixin');
var ReactCompositeComponent = require('ReactCompositeComponent');
var ReactDOM = require('ReactDOM');

Expand Down Expand Up @@ -58,7 +59,8 @@ function selectValueType(props, propName, componentName) {
*/
function updateOptions() {
/*jshint validthis:true */
var value = this.props.value != null ? this.props.value : this.state.value;
var propValue = this.getValue();
var value = propValue != null ? propValue : this.state.value;
var options = this.getDOMNode().options;
var selectedValue = '' + value;

Expand Down Expand Up @@ -89,6 +91,7 @@ function updateOptions() {
* selected.
*/
var ReactDOMSelect = ReactCompositeComponent.createClass({
mixins: [LinkedValueMixin],

propTypes: {
defaultValue: selectValueType,
Expand Down Expand Up @@ -128,9 +131,10 @@ var ReactDOMSelect = ReactCompositeComponent.createClass({

_handleChange: function(event) {
var returnValue;
if (this.props.onChange) {
var onChange = this.getOnChange();
if (onChange) {
this._isChanging = true;
returnValue = this.props.onChange(event);
returnValue = onChange(event);
this._isChanging = false;
}

Expand Down
19 changes: 12 additions & 7 deletions src/dom/components/ReactDOMTextarea.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
"use strict";

var DOMPropertyOperations = require('DOMPropertyOperations');
var LinkedValueMixin = require('LinkedValueMixin');
var ReactCompositeComponent = require('ReactCompositeComponent');
var ReactDOM = require('ReactDOM');

Expand Down Expand Up @@ -47,6 +48,7 @@ var CONTENT_TYPES = {'string': true, 'number': true};
* `defaultValue` if specified, or the children content (deprecated).
*/
var ReactDOMTextarea = ReactCompositeComponent.createClass({
mixins: [LinkedValueMixin],

getInitialState: function() {
var defaultValue = this.props.defaultValue;
Expand Down Expand Up @@ -80,10 +82,11 @@ var ReactDOMTextarea = ReactCompositeComponent.createClass({
if (defaultValue == null) {
defaultValue = '';
}
var value = this.getValue();
return {
// We save the initial value so that `ReactNativeComponent` doesn't update
// `textContent` (unnecessary since we update value).
initialValue: this.props.value != null ? this.props.value : defaultValue,
initialValue: value != null ? value : defaultValue,
value: defaultValue
};
},
Expand All @@ -96,15 +99,15 @@ var ReactDOMTextarea = ReactCompositeComponent.createClass({
render: function() {
// Clone `this.props` so we don't mutate the input.
var props = merge(this.props);
var value = this.getValue();

invariant(
props.dangerouslySetInnerHTML == null,
'`dangerouslySetInnerHTML` does not make sense on <textarea>.'
);

props.defaultValue = null;
props.value =
this.props.value != null ? this.props.value : this.state.value;
props.value = value != null ? value : this.state.value;
props.onChange = this._handleChange;

// Always set children to the same thing. In IE9, the selection range will
Expand All @@ -113,20 +116,22 @@ var ReactDOMTextarea = ReactCompositeComponent.createClass({
},

componentDidUpdate: function(prevProps, prevState, rootNode) {
if (this.props.value != null) {
var value = this.getValue();
if (value != null) {
DOMPropertyOperations.setValueForProperty(
rootNode,
'value',
this.props.value !== false ? '' + this.props.value : ''
value !== false ? '' + value : ''
);
}
},

_handleChange: function(event) {
var returnValue;
if (this.props.onChange) {
var onChange = this.getOnChange();
if (onChange) {
this._isChanging = true;
returnValue = this.props.onChange(event);
returnValue = onChange(event);
this._isChanging = false;
}
this.setState({value: event.target.value});
Expand Down
Loading

0 comments on commit fc73bf0

Please sign in to comment.