@samitouri / QOS-React-2 / commits / fc73bf0a0a

ReactLink: two-way binding for React

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.

Pete Hunt committed Sep 24, 2013 at 12:13 UTC fc73bf0a0abf739a9a8e6b1a5197dab113e76f27
10 files changed +343 -17
src/addons/link/LinkedStateMixin.js new
+46
@@ -0,0 +1,46 @@
1 +/**
2 + * Copyright 2013 Facebook, Inc.
3 + *
4 + * Licensed under the Apache License, Version 2.0 (the "License");
5 + * you may not use this file except in compliance with the License.
6 + * You may obtain a copy of the License at
7 + *
8 + * http://www.apache.org/licenses/LICENSE-2.0
9 + *
10 + * Unless required by applicable law or agreed to in writing, software
11 + * distributed under the License is distributed on an "AS IS" BASIS,
12 + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 + * See the License for the specific language governing permissions and
14 + * limitations under the License.
15 + *
16 + * @providesModule LinkedStateMixin
17 + * @typechecks static-only
18 + */
19 +
20 +"use strict";
21 +
22 +var ReactLink = require('ReactLink');
23 +var ReactStateSetters = require('ReactStateSetters');
24 +
25 +/**
26 + * A simple mixin around ReactLink.forState().
27 + */
28 +var LinkedStateMixin = {
29 + /**
30 + * Create a ReactLink that's linked to part of this component's state. The
31 + * ReactLink will have the current value of this.state[key] and will call
32 + * setState() when a change is requested.
33 + *
34 + * @param {string} key state key to update. Note: you may want to use keyOf()
35 + * if you're using Google Closure Compiler advanced mode.
36 + * @return {ReactLink} ReactLink instance linking to the state.
37 + */
38 + linkState: function(key) {
39 + return new ReactLink(
40 + this.state[key],
41 + ReactStateSetters.createStateKeySetter(this, key)
42 + );
43 + }
44 +};
45 +
46 +module.exports = LinkedStateMixin;
src/addons/link/ReactLink.js new
+54
@@ -0,0 +1,54 @@
1 +/**
2 + * Copyright 2013 Facebook, Inc.
3 + *
4 + * Licensed under the Apache License, Version 2.0 (the "License");
5 + * you may not use this file except in compliance with the License.
6 + * You may obtain a copy of the License at
7 + *
8 + * http://www.apache.org/licenses/LICENSE-2.0
9 + *
10 + * Unless required by applicable law or agreed to in writing, software
11 + * distributed under the License is distributed on an "AS IS" BASIS,
12 + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 + * See the License for the specific language governing permissions and
14 + * limitations under the License.
15 + *
16 + * @providesModule ReactLink
17 + * @typechecks static-only
18 + */
19 +
20 +"use strict";
21 +
22 +/**
23 + * ReactLink encapsulates a common pattern in which a component wants to modify
24 + * a prop received from its parent. ReactLink allows the parent to pass down a
25 + * value coupled with a callback that, when invoked, expresses an intent to
26 + * modify that value. For example:
27 + *
28 + * React.createClass({
29 + * getInitialState: function() {
30 + * return {value: ''};
31 + * },
32 + * render: function() {
33 + * var valueLink = new ReactLink(this.state.value, this._handleValueChange);
34 + * return <input valueLink={valueLink} />;
35 + * },
36 + * this._handleValueChange: function(newValue) {
37 + * this.setState({value: newValue});
38 + * }
39 + * });
40 + *
41 + * We have provided some sugary mixins to make the creation and
42 + * consumption of ReactLink easier; see LinkedValueMixin and LinkedStateMixin.
43 + */
44 +
45 +/**
46 + * @param {*} value current value of the link
47 + * @param {function} requestChange callback to request a change
48 + */
49 +function ReactLink(value, requestChange) {
50 + this.value = value;
51 + this.requestChange = requestChange;
52 +}
53 +
54 +module.exports = ReactLink;
src/addons/link/__tests__/LinkedStateMixin-test.js new
+56
@@ -0,0 +1,56 @@
1 +/**
2 + * Copyright 2013 Facebook, Inc.
3 + *
4 + * Licensed under the Apache License, Version 2.0 (the "License");
5 + * you may not use this file except in compliance with the License.
6 + * You may obtain a copy of the License at
7 + *
8 + * http://www.apache.org/licenses/LICENSE-2.0
9 + *
10 + * Unless required by applicable law or agreed to in writing, software
11 + * distributed under the License is distributed on an "AS IS" BASIS,
12 + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 + * See the License for the specific language governing permissions and
14 + * limitations under the License.
15 + *
16 + * @jsx React.DOM
17 + * @emails react-core
18 + */
19 +
20 +"use strict";
21 +
22 +/*jshint evil:true */
23 +
24 +describe('LinkedStateMixin', function() {
25 + var LinkedStateMixin;
26 + var React;
27 + var ReactLink;
28 +
29 + beforeEach(function() {
30 + LinkedStateMixin = require('LinkedStateMixin');
31 + React = require('React');
32 + ReactLink = require('ReactLink');
33 + });
34 +
35 + it('should create a ReactLink for state', function() {
36 + var Component = React.createClass({
37 + mixins: [LinkedStateMixin],
38 +
39 + getInitialState: function() {
40 + return {value: 'initial value'};
41 + },
42 +
43 + render: function() {
44 + return <span>value is {this.state.value}</span>;
45 + }
46 + });
47 + var container = document.createElement('div');
48 + var component = React.renderComponent(<Component />, container);
49 + var link = component.linkState('value');
50 + expect(component.state.value).toBe('initial value');
51 + expect(link.value).toBe('initial value');
52 + link.requestChange('new value');
53 + expect(component.state.value).toBe('new value');
54 + expect(component.linkState('value').value).toBe('new value');
55 + });
56 +});
src/dom/components/LinkedValueMixin.js new
+68
@@ -0,0 +1,68 @@
1 +/**
2 + * Copyright 2013 Facebook, Inc.
3 + *
4 + * Licensed under the Apache License, Version 2.0 (the "License");
5 + * you may not use this file except in compliance with the License.
6 + * You may obtain a copy of the License at
7 + *
8 + * http://www.apache.org/licenses/LICENSE-2.0
9 + *
10 + * Unless required by applicable law or agreed to in writing, software
11 + * distributed under the License is distributed on an "AS IS" BASIS,
12 + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 + * See the License for the specific language governing permissions and
14 + * limitations under the License.
15 + *
16 + * @providesModule LinkedValueMixin
17 + * @typechecks static-only
18 + */
19 +
20 +"use strict";
21 +
22 +var invariant = require('invariant');
23 +
24 +/**
25 + * Provide a linked `value` attribute for controlled forms. You should not use
26 + * this outside of the ReactDOM controlled form components.
27 + */
28 +var LinkedValueMixin = {
29 + _assertLink: function() {
30 + invariant(
31 + this.props.value == null && this.props.onChange == null,
32 + 'Cannot provide a valueLink and a value or onChange event. If you ' +
33 + 'want to use value or onChange, you probably don\'t want to use ' +
34 + 'valueLink'
35 + );
36 + },
37 +
38 + /**
39 + * @return {*} current value of the input either from value prop or link.
40 + */
41 + getValue: function() {
42 + if (this.props.valueLink) {
43 + this._assertLink();
44 + return this.props.valueLink.value;
45 + }
46 + return this.props.value;
47 + },
48 +
49 + /**
50 + * @return {function} change callback either from onChange prop or link.
51 + */
52 + getOnChange: function() {
53 + if (this.props.valueLink) {
54 + this._assertLink();
55 + return this._handleLinkedValueChange;
56 + }
57 + return this.props.onChange;
58 + },
59 +
60 + /**
61 + * @param {SyntheticEvent} e change event to handle
62 + */
63 + _handleLinkedValueChange: function(e) {
64 + this.props.valueLink.requestChange(e.target.value);
65 + }
66 +};
67 +
68 +module.exports = LinkedValueMixin;
src/dom/components/ReactDOMInput.js
+12 -6
@@ -19,6 +19,7 @@
19 "use strict";
20
21 var DOMPropertyOperations = require('DOMPropertyOperations');
22 +var LinkedValueMixin = require('LinkedValueMixin');
23 var ReactCompositeComponent = require('ReactCompositeComponent');
24 var ReactDOM = require('ReactDOM');
25 var ReactMount = require('ReactMount');
@@ -48,6 +49,7 @@ var instancesByReactID = {};
49 * @see http://www.w3.org/TR/2012/WD-html5-20121025/the-input-element.html
50 */
51 var ReactDOMInput = ReactCompositeComponent.createClass({
52 + mixins: [LinkedValueMixin],
53
54 getInitialState: function() {
55 var defaultValue = this.props.defaultValue;
@@ -71,8 +73,9 @@ var ReactDOMInput = ReactCompositeComponent.createClass({
73 props.checked =
74 this.props.checked != null ? this.props.checked : this.state.checked;
75
74 - props.value = this.props.value != null && this.props.value !== false
75 - ? '' + this.props.value
76 + var value = this.getValue();
77 + props.value = value != null && value !== false
78 + ? '' + value
79 : this.state.value;
80
81 props.onChange = this._handleChange;
@@ -99,22 +102,25 @@ var ReactDOMInput = ReactCompositeComponent.createClass({
102 this.props.checked || false
103 );
104 }
102 - if (this.props.value != null) {
105 +
106 + var value = this.getValue();
107 + if (value != null) {
108 // Cast `this.props.value` to a string so falsey values that cast to
109 // truthy strings are not ignored.
110 DOMPropertyOperations.setValueForProperty(
111 rootNode,
112 'value',
108 - this.props.value !== false ? '' + this.props.value : ''
113 + value !== false ? '' + value : ''
114 );
115 }
116 },
117
118 _handleChange: function(event) {
119 var returnValue;
115 - if (this.props.onChange) {
120 + var onChange = this.getOnChange();
121 + if (onChange) {
122 this._isChanging = true;
117 - returnValue = this.props.onChange(event);
123 + returnValue = onChange(event);
124 this._isChanging = false;
125 }
126 this.setState({
src/dom/components/ReactDOMSelect.js
+7 -3
@@ -18,6 +18,7 @@
18
19 "use strict";
20
21 +var LinkedValueMixin = require('LinkedValueMixin');
22 var ReactCompositeComponent = require('ReactCompositeComponent');
23 var ReactDOM = require('ReactDOM');
24
@@ -58,7 +59,8 @@ function selectValueType(props, propName, componentName) {
59 */
60 function updateOptions() {
61 /*jshint validthis:true */
61 - var value = this.props.value != null ? this.props.value : this.state.value;
62 + var propValue = this.getValue();
63 + var value = propValue != null ? propValue : this.state.value;
64 var options = this.getDOMNode().options;
65 var selectedValue = '' + value;
66
@@ -89,6 +91,7 @@ function updateOptions() {
91 * selected.
92 */
93 var ReactDOMSelect = ReactCompositeComponent.createClass({
94 + mixins: [LinkedValueMixin],
95
96 propTypes: {
97 defaultValue: selectValueType,
@@ -128,9 +131,10 @@ var ReactDOMSelect = ReactCompositeComponent.createClass({
131
132 _handleChange: function(event) {
133 var returnValue;
131 - if (this.props.onChange) {
134 + var onChange = this.getOnChange();
135 + if (onChange) {
136 this._isChanging = true;
133 - returnValue = this.props.onChange(event);
137 + returnValue = onChange(event);
138 this._isChanging = false;
139 }
140
src/dom/components/ReactDOMTextarea.js
+12 -7
@@ -19,6 +19,7 @@
19 "use strict";
20
21 var DOMPropertyOperations = require('DOMPropertyOperations');
22 +var LinkedValueMixin = require('LinkedValueMixin');
23 var ReactCompositeComponent = require('ReactCompositeComponent');
24 var ReactDOM = require('ReactDOM');
25
@@ -47,6 +48,7 @@ var CONTENT_TYPES = {'string': true, 'number': true};
48 * `defaultValue` if specified, or the children content (deprecated).
49 */
50 var ReactDOMTextarea = ReactCompositeComponent.createClass({
51 + mixins: [LinkedValueMixin],
52
53 getInitialState: function() {
54 var defaultValue = this.props.defaultValue;
@@ -80,10 +82,11 @@ var ReactDOMTextarea = ReactCompositeComponent.createClass({
82 if (defaultValue == null) {
83 defaultValue = '';
84 }
85 + var value = this.getValue();
86 return {
87 // We save the initial value so that `ReactNativeComponent` doesn't update
88 // `textContent` (unnecessary since we update value).
86 - initialValue: this.props.value != null ? this.props.value : defaultValue,
89 + initialValue: value != null ? value : defaultValue,
90 value: defaultValue
91 };
92 },
@@ -96,6 +99,7 @@ var ReactDOMTextarea = ReactCompositeComponent.createClass({
99 render: function() {
100 // Clone `this.props` so we don't mutate the input.
101 var props = merge(this.props);
102 + var value = this.getValue();
103
104 invariant(
105 props.dangerouslySetInnerHTML == null,
@@ -103,8 +107,7 @@ var ReactDOMTextarea = ReactCompositeComponent.createClass({
107 );
108
109 props.defaultValue = null;
106 - props.value =
107 - this.props.value != null ? this.props.value : this.state.value;
110 + props.value = value != null ? value : this.state.value;
111 props.onChange = this._handleChange;
112
113 // Always set children to the same thing. In IE9, the selection range will
@@ -113,20 +116,22 @@ var ReactDOMTextarea = ReactCompositeComponent.createClass({
116 },
117
118 componentDidUpdate: function(prevProps, prevState, rootNode) {
116 - if (this.props.value != null) {
119 + var value = this.getValue();
120 + if (value != null) {
121 DOMPropertyOperations.setValueForProperty(
122 rootNode,
123 'value',
120 - this.props.value !== false ? '' + this.props.value : ''
124 + value !== false ? '' + value : ''
125 );
126 }
127 },
128
129 _handleChange: function(event) {
130 var returnValue;
127 - if (this.props.onChange) {
131 + var onChange = this.getOnChange();
132 + if (onChange) {
133 this._isChanging = true;
129 - returnValue = this.props.onChange(event);
134 + returnValue = onChange(event);
135 this._isChanging = false;
136 }
137 this.setState({value: event.target.value});
src/dom/components/__tests__/ReactDOMInput-test.js
+37
@@ -21,14 +21,19 @@
21
22 /*jshint evil:true */
23
24 +var emptyFunction = require('emptyFunction');
25 +var mocks = require('mocks');
26 +
27 describe('ReactDOMInput', function() {
28 var React;
29 + var ReactLink;
30 var ReactTestUtils;
31
32 var renderTextInput;
33
34 beforeEach(function() {
35 React = require('React');
36 + ReactLink = require('ReactLink');
37 ReactTestUtils = require('ReactTestUtils');
38
39 renderTextInput = function(component) {
@@ -117,4 +122,36 @@ describe('ReactDOMInput', function() {
122 expect(cNode.checked).toBe(true);
123 });
124
125 + it('should support ReactLink', function() {
126 + var container = document.createElement('div');
127 + var link = new ReactLink('yolo', mocks.getMockFunction());
128 + var instance = <input type="text" valueLink={link} />;
129 +
130 + React.renderComponent(instance, container);
131 +
132 + expect(instance.getDOMNode().value).toBe('yolo');
133 + expect(link.value).toBe('yolo');
134 + expect(link.requestChange.mock.calls.length).toBe(0);
135 +
136 + instance.getDOMNode().value = 'test';
137 + ReactTestUtils.Simulate.input(instance.getDOMNode());
138 +
139 + expect(link.requestChange.mock.calls.length).toBe(1);
140 + expect(link.requestChange.mock.calls[0][0]).toEqual('test');
141 + });
142 +
143 + it('should throw if both value and valueLink are provided', function() {
144 + var node = document.createElement('div');
145 + var link = new ReactLink('yolo', mocks.getMockFunction());
146 + var instance = <input type="text" valueLink={link} />;
147 +
148 + expect(React.renderComponent.bind(React, instance, node)).not.toThrow();
149 +
150 + instance = <input type="text" valueLink={link} value="test" />;
151 + expect(React.renderComponent.bind(React, instance, node)).toThrow();
152 +
153 + instance = <input type="text" valueLink={link} onChange={emptyFunction} />;
154 + expect(React.renderComponent.bind(React, instance, node)).toThrow();
155 +
156 + });
157 });
src/dom/components/__tests__/ReactDOMSelect-test.js
+28
@@ -21,14 +21,18 @@
21
22 /*jshint evil:true */
23
24 +var mocks = require('mocks');
25 +
26 describe('ReactDOMSelect', function() {
27 var React;
28 + var ReactLink;
29 var ReactTestUtils;
30
31 var renderSelect;
32
33 beforeEach(function() {
34 React = require('React');
35 + ReactLink = require('ReactLink');
36 ReactTestUtils = require('ReactTestUtils');
37
38 renderSelect = function(component) {
@@ -154,4 +158,28 @@ describe('ReactDOMSelect', function() {
158 expect(node.options[1].selected).toBe(true); // giraffe
159 expect(node.options[2].selected).toBe(false); // gorilla
160 });
161 +
162 + it('should support ReactLink', function() {
163 + var link = new ReactLink('giraffe', mocks.getMockFunction());
164 + var stub =
165 + <select valueLink={link}>
166 + <option value="monkey">A monkey!</option>
167 + <option value="giraffe">A giraffe!</option>
168 + <option value="gorilla">A gorilla!</option>
169 + </select>;
170 + var node = renderSelect(stub);
171 +
172 + expect(node.options[0].selected).toBe(false); // monkey
173 + expect(node.options[1].selected).toBe(true); // giraffe
174 + expect(node.options[2].selected).toBe(false); // gorilla
175 + expect(link.requestChange.mock.calls.length).toBe(0);
176 +
177 + node.options[1].selected = false;
178 + node.options[2].selected = true;
179 + ReactTestUtils.Simulate.change(node);
180 +
181 + expect(link.requestChange.mock.calls.length).toBe(1);
182 + expect(link.requestChange.mock.calls[0][0]).toEqual('gorilla');
183 +
184 + });
185 });
src/dom/components/__tests__/ReactDOMTextarea-test.js
+23 -1
@@ -21,14 +21,18 @@
21
22 /*jshint evil:true */
23
24 +var mocks = require('mocks');
25 +
26 describe('ReactDOMTextarea', function() {
27 var React;
28 + var ReactLink;
29 var ReactTestUtils;
30
31 var renderTextarea;
32
33 beforeEach(function() {
34 React = require('React');
35 + ReactLink = require('ReactLink');
36 ReactTestUtils = require('ReactTestUtils');
37
38 renderTextarea = function(component) {
@@ -119,7 +123,7 @@ describe('ReactDOMTextarea', function() {
123 expect(node.value).toBe('17');
124 });
125
122 - it("should throw with multiple or invalid children", function() {
126 + it('should throw with multiple or invalid children', function() {
127 spyOn(console, 'warn');
128
129 expect(function() {
@@ -138,4 +142,22 @@ describe('ReactDOMTextarea', function() {
142
143 expect(console.warn.argsForCall.length).toBe(2);
144 });
145 +
146 + it('should support ReactLink', function() {
147 + var container = document.createElement('div');
148 + var link = new ReactLink('yolo', mocks.getMockFunction());
149 + var instance = <textarea valueLink={link} />;
150 +
151 + React.renderComponent(instance, container);
152 +
153 + expect(instance.getDOMNode().value).toBe('yolo');
154 + expect(link.value).toBe('yolo');
155 + expect(link.requestChange.mock.calls.length).toBe(0);
156 +
157 + instance.getDOMNode().value = 'test';
158 + ReactTestUtils.Simulate.input(instance.getDOMNode());
159 +
160 + expect(link.requestChange.mock.calls.length).toBe(1);
161 + expect(link.requestChange.mock.calls[0][0]).toEqual('test');
162 + });
163 });