├── .gitignore
├── LICENSE
├── README.md
├── benchmark
└── index.js
├── build
└── global
│ ├── react-derive.js
│ └── react-derive.min.js
├── examples
├── component-demo-es6
│ ├── app.css
│ ├── app.js
│ ├── demo.html
│ └── index.html
├── component-demo
│ ├── app.css
│ ├── app.js
│ ├── demo.html
│ └── index.html
├── decorator-demo-es6
│ ├── app.css
│ ├── app.js
│ ├── demo.html
│ └── index.html
├── decorator-demo
│ ├── app.css
│ ├── app.js
│ ├── demo.html
│ └── index.html
├── global.css
├── index.html
├── webpack.build.config.js
└── webpack.config.js
├── package-npm.js
├── package.json
├── src
├── __tests__
│ └── index.spec.js
└── index.js
└── webpack.config.js
/.gitignore:
--------------------------------------------------------------------------------
1 | .DS_Store
2 | build/npm
3 | node_modules/
4 | npm-debug.log
5 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 |
3 | Copyright (c) 2015 Gil Birman
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
23 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # react-derive
2 |
3 | For **organizing** and **optimizing** rendering of react components
4 | that rely on derived data. Works by wrapping your component in a HoC.
5 | For example, lets say your
6 | component shows the result of adding two numbers.
7 |
8 | export default class Add extends Component {
9 | render() {
10 | const {a,b,fontSize} = this.props;
11 | return
a + b = {a+b}
12 | }
13 | }
14 |
15 | We can move the calculation of `a+b` to a decorator named `@derive`
16 | where we'll create the *deriver* function named `sum`. And because we
17 | named the function `sum`, the deriver's result will be passed
18 | into the `Add` component via a prop likewise named `sum`.
19 |
20 | @derive({
21 | sum({a,b}) { return a+b }
22 | })
23 | export default class Add extends Component {
24 | render() {
25 | const {sum,fontSize} = this.props;
26 | return a + b = {sum}
27 | }
28 | }
29 |
30 | Note that
31 |
32 | - The first argument to a *deriver* function is `newProps`.
33 | - The second argument is the previously derived props object
34 | (in this case it would look something like `{a:5,b:3,sum:8}`)
35 | - The value of `this` allows you to reference the result of other
36 | derivers like `this.sum()`.
37 |
38 | But wait, every time the component renders, `sum` will recalculate even
39 | if `a` and `b` didn't change. To optimize, we can memoize the calculation with `@track`
40 | so when the `fontSize` prop changes `sum` won't be recalculated.
41 |
42 | @derive({
43 | @track('a', 'b')
44 | sum({a,b}) { return a+b }
45 | })
46 | export default class Add extends Component {
47 | render() {
48 | const {sum,fontSize} = this.props;
49 | return a + b = {sum}
50 | }
51 | }
52 |
53 | We supply args `'a'` and `'b'` to the `@track`
54 | decorator to indicate that the `sum` deriver only
55 | cares about those two props. If `fontSize` changes,
56 | `sum` won't recalculate.
57 |
58 | -------
59 |
60 | This project is similar to [reselect](https://github.com/faassen/reselect)
61 | for redux. However, while reselect helps manage derived data from
62 | global state, react-derive manages derived data from props.
63 |
64 | ## `@derive` as a decorator
65 |
66 | You can use `this` object to depend on other derived props:
67 |
68 | @derive({
69 | @track('taxPercent')
70 | tax({taxPercent}) {
71 | return this.subtotal() * (taxPercent / 100);
72 | },
73 |
74 | @track('items')
75 | subtotal({items}) {
76 | return items.reduce((acc, item) => acc + item.value, 0);
77 | },
78 |
79 | @track('taxPercent')
80 | total({taxPercent}) {
81 | return this.subtotal() + this.tax();
82 | }
83 | })
84 | class Total extends React.Component {
85 | render() {
86 | return { this.props.total }
87 | }
88 | }
89 |
90 | See the [reselect version of the example above](https://github.com/faassen/reselect#example)
91 |
92 | ## `Derive` as a Component
93 |
94 | `options` prop is the same as first argument to `@derive`.
95 | The child is a function that accepts the derived props object
96 | as it's first argument:
97 |
98 |
99 | {({tax, subtotal, total}) =>
100 |
101 | tax: {tax}
102 | subtotal: {subtotal}
103 | total: {total}
104 |
105 | }
106 |
107 | ## ES6 support
108 |
109 | Using ES7 decorators is in fact optional. If you want to stick with
110 | ES6 constructs, it's easy to do:
111 |
112 | export const Add =
113 | derive({
114 | sum: track('a','b')
115 | (function({a,b}) { return a+b })
116 | }) // <--- function returned...
117 | (class Add extends Component { // <--- immediately invoked by passing in class
118 | render() {
119 | const {sum,fontSize} = this.props;
120 | return a + b = {sum}
121 | }
122 | });
123 |
124 | See the `examples/` dir of this repo for additional examples.
125 |
126 | ## install + import
127 |
128 | npm i react-derive -S
129 |
130 | then:
131 |
132 | import {Derive, derive, track} from 'react-derive';
133 |
134 | or when included via script tag it's available as the global variable `ReactDerive`:
135 |
136 | const {Derive, derive, track} = ReactDerive;
137 |
138 | ## [documentation](http://gilbox.github.io/react-derive/index.js.html)
139 |
140 | ## examples
141 |
142 | - decorator demo: [source](https://github.com/gilbox/react-derive/blob/master/examples/decorator-demo/app.js) - [live demo](http://gilbox.github.io/react-derive/examples/decorator-demo/demo.html)
143 | - component demo: [source](https://github.com/gilbox/react-derive/blob/master/examples/component-demo/app.js) - [live demo](http://gilbox.github.io/react-derive/examples/component-demo/demo.html)
144 | - [elegant-react-hot-demo](https://github.com/gilbox/elegant-react-hot-demo) (still a WIP)
145 |
--------------------------------------------------------------------------------
/benchmark/index.js:
--------------------------------------------------------------------------------
1 | import { derive, track } from '../src/index';
2 | import React, { Component } from 'react';
3 |
4 | const deriveOptions = {
5 | tax: track('taxPercent')
6 | (function({taxPercent}) {
7 | return this.subtotal() * (taxPercent / 100);
8 | }),
9 |
10 | subtotal: track('items')
11 | (function({items}) {
12 | return items.reduce((acc, item) => acc + item, 0);
13 | }),
14 |
15 | total: track('taxPercent')
16 | (function({taxPercent}) {
17 | return this.subtotal() + this.tax();
18 | })
19 | };
20 |
21 | const Calculated =
22 | derive({
23 | tax: track('taxPercent')
24 | (function({taxPercent}) {
25 | return this.subtotal() * (taxPercent / 100);
26 | }),
27 |
28 | subtotal: track('items')
29 | (function({items}) {
30 | return items.reduce((acc, item) => acc + item, 0);
31 | }),
32 |
33 | total: track('taxPercent')
34 | (function({taxPercent}) {
35 | return this.subtotal() + this.tax();
36 | })
37 | })
38 | (class Calculated extends Component {
39 | render() {
40 | const {tax,subtotal,total} = this.props;
41 |
42 | return
43 | tax: {tax}
44 | subtotal: {subtotal}
45 | total: {total}
46 |
47 | }
48 | });
49 |
50 | const t = Date.now();
51 |
52 | for (let j = 0; j < 10; j++) {
53 | const taxPercent = Math.random()*10;
54 | const items = [10,55,99,23,56].map(x => Math.random() * x);
55 |
56 | for (let i = 0; i < 1000; i++) {
57 | React.renderToStaticMarkup( );
58 | }
59 | }
60 |
61 | console.log('time:', Date.now() - t);
62 |
--------------------------------------------------------------------------------
/build/global/react-derive.js:
--------------------------------------------------------------------------------
1 | (function webpackUniversalModuleDefinition(root, factory) {
2 | if(typeof exports === 'object' && typeof module === 'object')
3 | module.exports = factory(require("react"));
4 | else if(typeof define === 'function' && define.amd)
5 | define(["react"], factory);
6 | else if(typeof exports === 'object')
7 | exports["ReactDerive"] = factory(require("react"));
8 | else
9 | root["ReactDerive"] = factory(root["React"]);
10 | })(this, function(__WEBPACK_EXTERNAL_MODULE_1__) {
11 | return /******/ (function(modules) { // webpackBootstrap
12 | /******/ // The module cache
13 | /******/ var installedModules = {};
14 |
15 | /******/ // The require function
16 | /******/ function __webpack_require__(moduleId) {
17 |
18 | /******/ // Check if module is in cache
19 | /******/ if(installedModules[moduleId])
20 | /******/ return installedModules[moduleId].exports;
21 |
22 | /******/ // Create a new module (and put it into the cache)
23 | /******/ var module = installedModules[moduleId] = {
24 | /******/ exports: {},
25 | /******/ id: moduleId,
26 | /******/ loaded: false
27 | /******/ };
28 |
29 | /******/ // Execute the module function
30 | /******/ modules[moduleId].call(module.exports, module, module.exports, __webpack_require__);
31 |
32 | /******/ // Flag the module as loaded
33 | /******/ module.loaded = true;
34 |
35 | /******/ // Return the exports of the module
36 | /******/ return module.exports;
37 | /******/ }
38 |
39 |
40 | /******/ // expose the modules object (__webpack_modules__)
41 | /******/ __webpack_require__.m = modules;
42 |
43 | /******/ // expose the module cache
44 | /******/ __webpack_require__.c = installedModules;
45 |
46 | /******/ // __webpack_public_path__
47 | /******/ __webpack_require__.p = "";
48 |
49 | /******/ // Load entry module and return exports
50 | /******/ return __webpack_require__(0);
51 | /******/ })
52 | /************************************************************************/
53 | /******/ ([
54 | /* 0 */
55 | /***/ function(module, exports, __webpack_require__) {
56 |
57 | 'use strict';
58 |
59 | Object.defineProperty(exports, '__esModule', {
60 | value: true
61 | });
62 |
63 | var _extends = Object.assign || function (target) { for (var i = 1; i < arguments.length; i++) { var source = arguments[i]; for (var key in source) { if (Object.prototype.hasOwnProperty.call(source, key)) { target[key] = source[key]; } } } return target; };
64 |
65 | var _createClass = (function () { function defineProperties(target, props) { for (var i = 0; i < props.length; i++) { var descriptor = props[i]; descriptor.enumerable = descriptor.enumerable || false; descriptor.configurable = true; if ('value' in descriptor) descriptor.writable = true; Object.defineProperty(target, descriptor.key, descriptor); } } return function (Constructor, protoProps, staticProps) { if (protoProps) defineProperties(Constructor.prototype, protoProps); if (staticProps) defineProperties(Constructor, staticProps); return Constructor; }; })();
66 |
67 | var _get = function get(_x3, _x4, _x5) { var _again = true; _function: while (_again) { var object = _x3, property = _x4, receiver = _x5; desc = parent = getter = undefined; _again = false; if (object === null) object = Function.prototype; var desc = Object.getOwnPropertyDescriptor(object, property); if (desc === undefined) { var parent = Object.getPrototypeOf(object); if (parent === null) { return undefined; } else { _x3 = parent; _x4 = property; _x5 = receiver; _again = true; continue _function; } } else if ('value' in desc) { return desc.value; } else { var getter = desc.get; if (getter === undefined) { return undefined; } return getter.call(receiver); } } };
68 |
69 | exports.derive = derive;
70 | exports.track = track;
71 |
72 | function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { 'default': obj }; }
73 |
74 | function _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError('Cannot call a class as a function'); } }
75 |
76 | function _inherits(subClass, superClass) { if (typeof superClass !== 'function' && superClass !== null) { throw new TypeError('Super expression must either be null or a function, not ' + typeof superClass); } subClass.prototype = Object.create(superClass && superClass.prototype, { constructor: { value: subClass, enumerable: false, writable: true, configurable: true } }); if (superClass) subClass.__proto__ = superClass; }
77 |
78 | var _react = __webpack_require__(1);
79 |
80 | var _react2 = _interopRequireDefault(_react);
81 |
82 | var BLOCKED = {};
83 |
84 | var globalOptions = { debug: false };
85 |
86 | exports.globalOptions = globalOptions;
87 | /**
88 | * ## derive
89 | *
90 | * Create a derived data higher-order component (HoC).
91 | *
92 | * @param {Object} options (optional)
93 | * @param {Boolean} debug (optional)
94 | * @return {Object}
95 | */
96 |
97 | function derive() {
98 | var options = arguments.length <= 0 || arguments[0] === undefined ? {} : arguments[0];
99 |
100 | return function (DecoratedComponent) {
101 | return (function (_Component) {
102 | _inherits(DeriveDecorator, _Component);
103 |
104 | function DeriveDecorator() {
105 | _classCallCheck(this, DeriveDecorator);
106 |
107 | _get(Object.getPrototypeOf(DeriveDecorator.prototype), 'constructor', this).apply(this, arguments);
108 | }
109 |
110 | _createClass(DeriveDecorator, [{
111 | key: 'componentWillMount',
112 | value: function componentWillMount() {
113 | this.derivedProps = deriveProps(options, {}, this.props, {});
114 | }
115 | }, {
116 | key: 'componentWillUpdate',
117 | value: function componentWillUpdate(nextProps) {
118 | this.derivedProps = deriveProps(options, this.props, nextProps, this.derivedProps || {});
119 | }
120 | }, {
121 | key: 'render',
122 | value: function render() {
123 | return _react2['default'].createElement(DecoratedComponent, this.derivedProps);
124 | }
125 | }], [{
126 | key: 'displayName',
127 | value: 'Derive(' + getDisplayName(DecoratedComponent) + ')',
128 | enumerable: true
129 | }, {
130 | key: 'DecoratedComponent',
131 | value: DecoratedComponent,
132 | enumerable: true
133 | }]);
134 |
135 | return DeriveDecorator;
136 | })(_react.Component);
137 | };
138 | }
139 |
140 | // `deriveProps` takes props from the previous render (`prevProps`, `derivedProps`),
141 | // and props from the current render (`nextProps`) and calculates the next derived props.
142 | function deriveProps(options, prevProps, nextProps, derivedProps) {
143 | var nextDerivedProps = {};
144 |
145 | var calcDerivedProp = function calcDerivedProp(key, xf) {
146 |
147 | // When `xf` is annotated with `trackedProps` (by `@track`), only re-calculate
148 | // derived props when the tracked props changed.
149 | if (xf.trackedProps && xf.trackedProps.every(function (p) {
150 | return prevProps[p] === nextProps[p];
151 | })) {
152 | return derivedProps[key];
153 | }
154 |
155 | if (globalOptions.debug) console.log('Recalculating derived prop \'' + key + '\'');
156 | return xf.call(delegates, nextProps, derivedProps);
157 | };
158 |
159 | // `delegates` is the object that will be attached to the `this` Object
160 | // of deriver (`xf`) functions. (see `xf.call(delegates...)` above)
161 | var delegates = map.call(options, function (xf, key) {
162 | return function () {
163 | if (!nextDerivedProps.hasOwnProperty(key)) {
164 | nextDerivedProps[key] = BLOCKED;
165 | return nextDerivedProps[key] = calcDerivedProp(key, xf);
166 | } else {
167 | if (nextDerivedProps[key] === BLOCKED) {
168 | throw Error('Circular dependencies in derived props, \'' + key + '\' was blocked.');
169 | }
170 | return nextDerivedProps[key];
171 | }
172 | };
173 | });
174 |
175 | Object.keys(options).forEach(function (key) {
176 | if (!nextDerivedProps.hasOwnProperty(key))
177 | // calculate derived prop
178 | nextDerivedProps[key] = calcDerivedProp(key, options[key]);
179 | });
180 |
181 | return _extends({}, nextProps, nextDerivedProps);
182 | }
183 |
184 | function getDisplayName(comp) {
185 | return comp.displayName || comp.name || 'Component';
186 | }
187 |
188 | // map an object to an object
189 | function map(f) {
190 | var _this = this;
191 |
192 | var result = arguments.length <= 1 || arguments[1] === undefined ? {} : arguments[1];
193 |
194 | Object.keys(this).forEach(function (k) {
195 | return result[k] = f(_this[k], k);
196 | });
197 | return result;
198 | }
199 |
200 | /**
201 | * ## track
202 | *
203 | * Object literal decorator that annotates a mapper function
204 | * to have a 'trackedProps' property. Used by `@derive` to memoize
205 | * props.
206 | *
207 | * @param {String...} trackedProps
208 | * @return {Function}
209 | */
210 |
211 | function track() {
212 | for (var _len = arguments.length, trackedProps = Array(_len), _key = 0; _key < _len; _key++) {
213 | trackedProps[_key] = arguments[_key];
214 | }
215 |
216 | return function (target, key, descriptor) {
217 | if (descriptor) {
218 | // ES7 decorator
219 | descriptor.value.trackedProps = trackedProps;
220 | } else {
221 | // ES6
222 | target.trackedProps = trackedProps;
223 | return target;
224 | }
225 | };
226 | }
227 |
228 | /**
229 | * ## Derive
230 | *
231 | * `@derive` as a component.
232 | * @prop {Object} options
233 | */
234 |
235 | var Derive = (function (_Component2) {
236 | _inherits(Derive, _Component2);
237 |
238 | function Derive() {
239 | _classCallCheck(this, Derive);
240 |
241 | _get(Object.getPrototypeOf(Derive.prototype), 'constructor', this).apply(this, arguments);
242 | }
243 |
244 | _createClass(Derive, [{
245 | key: 'componentWillMount',
246 | value: function componentWillMount() {
247 | this.derivedProps = deriveProps(this.props.options, {}, this.props, {});
248 | }
249 | }, {
250 | key: 'componentWillUpdate',
251 | value: function componentWillUpdate(nextProps) {
252 | this.derivedProps = deriveProps(nextProps.options, this.props, nextProps, this.derivedProps || {});
253 | }
254 | }, {
255 | key: 'render',
256 | value: function render() {
257 | return _react2['default'].Children.only(this.props.children(this.derivedProps));
258 | }
259 | }]);
260 |
261 | return Derive;
262 | })(_react.Component);
263 |
264 | exports.Derive = Derive;
265 |
266 | /***/ },
267 | /* 1 */
268 | /***/ function(module, exports) {
269 |
270 | module.exports = __WEBPACK_EXTERNAL_MODULE_1__;
271 |
272 | /***/ }
273 | /******/ ])
274 | });
275 | ;
--------------------------------------------------------------------------------
/build/global/react-derive.min.js:
--------------------------------------------------------------------------------
1 | !function(e,t){"object"==typeof exports&&"object"==typeof module?module.exports=t(require("react")):"function"==typeof define&&define.amd?define(["react"],t):"object"==typeof exports?exports.ReactDerive=t(require("react")):e.ReactDerive=t(e.React)}(this,function(e){return function(e){function t(n){if(r[n])return r[n].exports;var o=r[n]={exports:{},id:n,loaded:!1};return e[n].call(o.exports,o,o.exports,t),o.loaded=!0,o.exports}var r={};return t.m=e,t.c=r,t.p="",t(0)}([function(e,t,r){"use strict";function n(e){return e&&e.__esModule?e:{"default":e}}function o(e,t){if(!(e instanceof t))throw new TypeError("Cannot call a class as a function")}function i(e,t){if("function"!=typeof t&&null!==t)throw new TypeError("Super expression must either be null or a function, not "+typeof t);e.prototype=Object.create(t&&t.prototype,{constructor:{value:e,enumerable:!1,writable:!0,configurable:!0}}),t&&(e.__proto__=t)}function u(){var e=arguments.length<=0||void 0===arguments[0]?{}:arguments[0];return function(t){return function(r){function n(){o(this,n),d(Object.getPrototypeOf(n.prototype),"constructor",this).apply(this,arguments)}return i(n,r),f(n,[{key:"componentWillMount",value:function(){this.derivedProps=a(e,{},this.props,{})}},{key:"componentWillUpdate",value:function(t){this.derivedProps=a(e,this.props,t,this.derivedProps||{})}},{key:"render",value:function(){return y["default"].createElement(t,this.derivedProps)}}],[{key:"displayName",value:"Derive("+c(t)+")",enumerable:!0},{key:"DecoratedComponent",value:t,enumerable:!0}]),n}(v.Component)}}function a(e,t,r,n){var o={},i=function(e,o){return o.trackedProps&&o.trackedProps.every(function(e){return t[e]===r[e]})?n[e]:(m.debug&&console.log("Recalculating derived prop '"+e+"'"),o.call(u,r,n))},u=p.call(e,function(e,t){return function(){if(o.hasOwnProperty(t)){if(o[t]===h)throw Error("Circular dependencies in derived props, '"+t+"' was blocked.");return o[t]}return o[t]=h,o[t]=i(t,e)}});return Object.keys(e).forEach(function(t){o.hasOwnProperty(t)||(o[t]=i(t,e[t]))}),l({},r,o)}function c(e){return e.displayName||e.name||"Component"}function p(e){var t=this,r=arguments.length<=1||void 0===arguments[1]?{}:arguments[1];return Object.keys(this).forEach(function(n){return r[n]=e(t[n],n)}),r}function s(){for(var e=arguments.length,t=Array(e),r=0;e>r;r++)t[r]=arguments[r];return function(e,r,n){return n?void(n.value.trackedProps=t):(e.trackedProps=t,e)}}Object.defineProperty(t,"__esModule",{value:!0});var l=Object.assign||function(e){for(var t=1;t acc + item, 0);
15 | }),
16 |
17 | total: track('taxPercent')
18 | (function({taxPercent}) {
19 | return this.subtotal() + this.tax();
20 | })
21 | };
22 |
23 | class App extends Component {
24 | constructor(props) {
25 | super(props);
26 | this.state = {
27 | taxPercent: 5,
28 | items: [10,55,99,23,56]
29 | };
30 | }
31 |
32 | render() {
33 | const {taxPercent,items} = this.state;
34 |
35 | return
36 |
37 |
38 | Tax Percent:
39 |
42 | this.setState({taxPercent: event.target.value})} />
43 |
44 |
45 |
46 | {({tax, subtotal, total}) =>
47 |
48 |
49 | tax: {tax}
50 | subtotal: {subtotal}
51 | total: {total}
52 |
53 |
54 | }
55 |
56 |
57 | }
58 | }
59 |
60 | React.render( , document.getElementById('example'));
61 |
--------------------------------------------------------------------------------
/examples/component-demo-es6/demo.html:
--------------------------------------------------------------------------------
1 |
2 |
3 | Demo
4 |
5 |
6 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/examples/component-demo-es6/index.html:
--------------------------------------------------------------------------------
1 |
2 | Demo
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
--------------------------------------------------------------------------------
/examples/component-demo/app.css:
--------------------------------------------------------------------------------
1 | body {
2 | font-family: Georgia;
3 | padding: 50px;
4 | margin: 0; }
5 | ul { padding: 0; }
6 | li, button {
7 | border-radius: 3px;
8 | border: 1px solid grey;
9 | width: 100%;
10 | box-sizing: border-box;
11 | padding: 10px;
12 | cursor: pointer;
13 | list-style: none;
14 | text-align: center;
15 | margin-bottom: 30px;
16 |
17 | &:hover {
18 | border: 1px solid blue;
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/examples/component-demo/app.js:
--------------------------------------------------------------------------------
1 | import React, {Component} from 'react';
2 | import {Derive, track, globalOptions} from 'react-derive';
3 |
4 | globalOptions.debug = true;
5 |
6 | const deriveOptions = {
7 | @track('taxPercent')
8 | tax({taxPercent}) {
9 | return this.subtotal() * (taxPercent / 100);
10 | },
11 |
12 | @track('items')
13 | subtotal({items}) {
14 | return items.reduce((acc, item) => acc + item, 0);
15 | },
16 |
17 | @track('taxPercent')
18 | total({taxPercent}) {
19 | return this.subtotal() + this.tax();
20 | }
21 | };
22 |
23 | class App extends Component {
24 | constructor(props) {
25 | super(props);
26 | this.state = {
27 | taxPercent: 5,
28 | items: [10,55,99,23,56]
29 | };
30 | }
31 |
32 | render() {
33 | const {taxPercent,items} = this.state;
34 |
35 | return
36 |
37 |
38 | Tax Percent:
39 |
42 | this.setState({taxPercent: event.target.value})} />
43 |
44 |
45 |
46 | {({tax, subtotal, total}) =>
47 |
48 |
49 | tax: {tax}
50 | subtotal: {subtotal}
51 | total: {total}
52 |
53 |
54 | }
55 |
56 |
57 | }
58 | }
59 |
60 | React.render( , document.getElementById('example'));
61 |
--------------------------------------------------------------------------------
/examples/component-demo/demo.html:
--------------------------------------------------------------------------------
1 |
2 |
3 | Demo
4 |
5 |
6 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/examples/component-demo/index.html:
--------------------------------------------------------------------------------
1 |
2 | Demo
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
--------------------------------------------------------------------------------
/examples/decorator-demo-es6/app.css:
--------------------------------------------------------------------------------
1 | body {
2 | font-family: Georgia;
3 | padding: 50px;
4 | margin: 0; }
5 | ul { padding: 0; }
6 | li, button {
7 | border-radius: 3px;
8 | border: 1px solid grey;
9 | width: 100%;
10 | box-sizing: border-box;
11 | padding: 10px;
12 | cursor: pointer;
13 | list-style: none;
14 | text-align: center;
15 | margin-bottom: 30px;
16 |
17 | &:hover {
18 | border: 1px solid blue;
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/examples/decorator-demo-es6/app.js:
--------------------------------------------------------------------------------
1 | import React, {Component} from 'react';
2 | import {derive, track, globalOptions} from 'react-derive';
3 |
4 | globalOptions.debug = true;
5 |
6 | const Calculated =
7 | derive({
8 | tax: track('taxPercent')
9 | (function({taxPercent}) {
10 | return this.subtotal() * (taxPercent / 100);
11 | }),
12 |
13 | subtotal: track('items')
14 | (function({items}) {
15 | return items.reduce((acc, item) => acc + item, 0);
16 | }),
17 |
18 | total: track('taxPercent')
19 | (function({taxPercent}) {
20 | return this.subtotal() + this.tax();
21 | })
22 | })
23 | (class Calculated extends Component {
24 | render() {
25 | const {tax,subtotal,total} = this.props;
26 |
27 | return
28 | tax: {tax}
29 | subtotal: {subtotal}
30 | total: {total}
31 |
32 | }
33 | });
34 |
35 | class App extends Component {
36 | constructor(props) {
37 | super(props);
38 | this.state = {
39 | taxPercent: 5,
40 | items: [10,55,99,23,56]
41 | };
42 | }
43 |
44 | render() {
45 | const {taxPercent,items} = this.state;
46 |
47 | return
48 |
49 |
50 | Tax Percent:
51 |
54 | this.setState({taxPercent: event.target.value})} />
55 |
56 |
57 |
58 |
59 |
60 | }
61 | }
62 |
63 | React.render( , document.getElementById('example'));
64 |
--------------------------------------------------------------------------------
/examples/decorator-demo-es6/demo.html:
--------------------------------------------------------------------------------
1 |
2 |
3 | Demo
4 |
5 |
6 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/examples/decorator-demo-es6/index.html:
--------------------------------------------------------------------------------
1 |
2 | Demo
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
--------------------------------------------------------------------------------
/examples/decorator-demo/app.css:
--------------------------------------------------------------------------------
1 | body {
2 | font-family: Georgia;
3 | padding: 50px;
4 | margin: 0; }
5 | ul { padding: 0; }
6 | li, button {
7 | border-radius: 3px;
8 | border: 1px solid grey;
9 | width: 100%;
10 | box-sizing: border-box;
11 | padding: 10px;
12 | cursor: pointer;
13 | list-style: none;
14 | text-align: center;
15 | margin-bottom: 30px;
16 |
17 | &:hover {
18 | border: 1px solid blue;
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/examples/decorator-demo/app.js:
--------------------------------------------------------------------------------
1 | import React, {Component} from 'react';
2 | import {derive, track, globalOptions} from 'react-derive';
3 |
4 | globalOptions.debug = true;
5 |
6 | @derive({
7 | @track('taxPercent')
8 | tax({taxPercent}) {
9 | return this.subtotal() * (taxPercent / 100);
10 | },
11 |
12 | @track('items')
13 | subtotal({items}) {
14 | return items.reduce((acc, item) => acc + item, 0);
15 | },
16 |
17 | @track('taxPercent')
18 | total({taxPercent}) {
19 | return this.subtotal() + this.tax();
20 | }
21 | })
22 | class Calculated extends Component {
23 | render() {
24 | const {tax,subtotal,total} = this.props;
25 |
26 | return
27 | tax: {tax}
28 | subtotal: {subtotal}
29 | total: {total}
30 |
31 | }
32 | }
33 |
34 | class App extends Component {
35 | constructor(props) {
36 | super(props);
37 | this.state = {
38 | taxPercent: 5,
39 | items: [10,55,99,23,56]
40 | };
41 | }
42 |
43 | render() {
44 | const {taxPercent,items} = this.state;
45 |
46 | return
47 |
48 |
49 | Tax Percent:
50 |
53 | this.setState({taxPercent: event.target.value})} />
54 |
55 |
56 |
57 |
58 |
59 | }
60 | }
61 |
62 | React.render( , document.getElementById('example'));
63 |
--------------------------------------------------------------------------------
/examples/decorator-demo/demo.html:
--------------------------------------------------------------------------------
1 |
2 |
3 | Demo
4 |
5 |
6 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/examples/decorator-demo/index.html:
--------------------------------------------------------------------------------
1 |
2 | Demo
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
--------------------------------------------------------------------------------
/examples/global.css:
--------------------------------------------------------------------------------
1 | body {
2 | font-family: "Helvetica Neue", Arial;
3 | font-weight: 200;
4 | }
5 |
6 | h1, h2, h3 {
7 | font-weight: 100;
8 | }
9 |
10 | a {
11 | color: hsl(200, 50%, 50%);
12 | }
13 |
14 | a.active {
15 | color: hsl(20, 50%, 50%);
16 | }
17 |
18 | .breadcrumbs a {
19 | text-decoration: none;
20 | }
21 |
--------------------------------------------------------------------------------
/examples/index.html:
--------------------------------------------------------------------------------
1 |
2 | Demo
3 |
4 |
5 | Demo
6 |
12 |
--------------------------------------------------------------------------------
/examples/webpack.build.config.js:
--------------------------------------------------------------------------------
1 | var config = require('./webpack.config');
2 |
3 | config.output.path = '../react-derive_gh-pages/examples/js';
4 |
5 | module.exports = config;
6 |
--------------------------------------------------------------------------------
/examples/webpack.config.js:
--------------------------------------------------------------------------------
1 | var fs = require('fs');
2 | var path = require('path');
3 | var webpack = require('webpack');
4 |
5 | function isDirectory(dir) {
6 | return fs.lstatSync(dir).isDirectory();
7 | }
8 |
9 | module.exports = {
10 |
11 | devtool: 'inline-source-map',
12 |
13 | entry: fs.readdirSync(__dirname).reduce(function (entries, dir) {
14 | var isDraft = dir.charAt(0) === '_';
15 |
16 | if (!isDraft && isDirectory(path.join(__dirname, dir)))
17 | entries[dir] = path.join(__dirname, dir, 'app.js');
18 |
19 | return entries;
20 | }, {}),
21 |
22 | output: {
23 | path: 'examples/__build__',
24 | filename: '[name].js',
25 | chunkFilename: '[id].chunk.js',
26 | publicPath: '/__build__/'
27 | },
28 |
29 | module: {
30 | loaders: [
31 | { test: /\.js$/, exclude: /node_modules/, loader: 'babel-loader?stage=0' }
32 | ]
33 | },
34 |
35 | resolve: {
36 | alias: {
37 | 'react-derive': path.resolve(__dirname + '../../src/')
38 | }
39 | },
40 |
41 | plugins: [
42 | new webpack.DefinePlugin({
43 | 'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV || 'development')
44 | })
45 | ]
46 |
47 | };
48 |
--------------------------------------------------------------------------------
/package-npm.js:
--------------------------------------------------------------------------------
1 | var p = require('./package');
2 |
3 | p.main='lib';
4 | p.scripts=p.devDependencies=undefined;
5 |
6 | module.exports = p;
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "react-derive",
3 | "version": "0.1.1",
4 | "description": "Derived data for your memoizing pleasure",
5 | "main": "src/",
6 | "repository": {
7 | "type": "git",
8 | "url": "https://github.com/gilbox/react-derive.git"
9 | },
10 | "homepage": "https://github.com/gilbox/react-derive",
11 | "bugs": "https://github.com/gilbox/react-derive/issues",
12 | "scripts": {
13 | "build-global": "rm -rf build/global && NODE_ENV=production webpack src/index.js build/global/react-derive.js && NODE_ENV=production COMPRESS=1 webpack src/index.js build/global/react-derive.min.js && echo \"gzipped, the global build is `gzip -c build/global/react-derive.min.js | wc -c` bytes\"",
14 | "build-npm": "rm -rf build/npm && babel -d build/npm/lib ./src --stage 0 && cp README.md build/npm && find -X build/npm/lib -type d -name __tests__ | xargs rm -rf && node -p 'p=require(\"./package-npm\");JSON.stringify(p,null,2)' > build/npm/package.json",
15 | "examples": "rm -rf examples/js && webpack-dev-server --config examples/webpack.config.js --content-base examples",
16 | "examples-build": "rm -rf ../react-derive_gh-pages/examples/ && cp -R examples/ ../react-derive_gh-pages/examples/ && webpack --config examples/webpack.build.config.js",
17 | "test": "BABEL_JEST_STAGE=0 jest",
18 | "publish": "npm publish ./build/npm",
19 | "prepush": "npm run examples-build",
20 | "docs": "docker -i src -I -o ../react-derive_gh-pages/ -s disable -c pastie"
21 | },
22 | "authors": [
23 | "Gil Birman (http://gilbox.me/)"
24 | ],
25 | "jest": {
26 | "scriptPreprocessor": "/node_modules/babel-jest",
27 | "testFileExtensions": [
28 | "es6",
29 | "js"
30 | ],
31 | "moduleFileExtensions": [
32 | "js",
33 | "json",
34 | "es6"
35 | ],
36 | "unmockedModulePathPatterns": [
37 | "react"
38 | ]
39 | },
40 | "license": "MIT",
41 | "devDependencies": {
42 | "babel": "^5.6.14",
43 | "babel-core": "^5.6.18",
44 | "babel-jest": "^5.3.0",
45 | "babel-loader": "^5.3.1",
46 | "docker": "^0.2.14",
47 | "immutable": "^3.7.3",
48 | "jest-cli": "^0.4.16",
49 | "node-libs-browser": "^0.5.2",
50 | "react": "^0.13.3",
51 | "webpack": "^1.10.1",
52 | "webpack-dev-server": "^1.10.1"
53 | },
54 | "peerDependencies": {},
55 | "dependencies": {},
56 | "tags": [
57 | "react",
58 | "react-native",
59 | "functional"
60 | ],
61 | "keywords": [
62 | "react",
63 | "react-native",
64 | "react-component"
65 | ]
66 | }
67 |
--------------------------------------------------------------------------------
/src/__tests__/index.spec.js:
--------------------------------------------------------------------------------
1 | jest.dontMock('../index');
2 | import {derive, track} from '../index';
3 | import React, {Component, addons} from 'react/addons';
4 | const {TestUtils} = addons;
5 |
6 | function createTestComponent(options, cb) {
7 | @derive(options)
8 | class TestComponent extends Component {
9 | render() {
10 | const props = this.props;
11 | cb(props);
12 | return null;
13 | }
14 | }
15 | return TestComponent;
16 | }
17 |
18 | function deriveProps(options, props) {
19 | var derivedProps;
20 | const Comp = createTestComponent(options, props => derivedProps = props);
21 | TestUtils.renderIntoDocument( );
22 | return derivedProps;
23 | }
24 |
25 | describe('derive', () => {
26 | it('simple case', () => {
27 | const options = {
28 | foo({bar}) {
29 | return bar+1;
30 | }
31 | };
32 | const derivedProps = deriveProps(options, { bar: 1 } )
33 | expect(derivedProps).toEqual({bar:1, foo:2});
34 | });
35 |
36 | it('multiple options', () => {
37 | const options = {
38 | foo({bar}) {
39 | return bar+1;
40 | },
41 | baz({bar}) {
42 | return bar+10;
43 | },
44 | x({x}) {
45 | return x + ' world';
46 | }
47 | };
48 | const derivedProps = deriveProps(options, {bar: 1, x: 'hello'} )
49 | expect(derivedProps).toEqual({bar:1, foo:2, baz: 11, x: 'hello world'});
50 | });
51 |
52 | it('simple case of deriving from derived data', () => {
53 | const options = {
54 | foo({bar}) {
55 | return bar+1;
56 | },
57 | baz({bar}) {
58 | return this.foo() + bar + 10;
59 | }
60 | };
61 | const derivedProps = deriveProps(options, {bar: 1} )
62 | expect(derivedProps).toEqual({bar:1, foo:2, baz: 13});
63 | });
64 |
65 | it('another case of deriving from derived data', () => {
66 | const options = {
67 | foo({bar}) {
68 | return this.baz() + bar + 1;
69 | },
70 | baz({bar}) {
71 | return bar + 10;
72 | }
73 | };
74 | const derivedProps = deriveProps(options, {bar: 1} )
75 | expect(derivedProps).toEqual({bar:1, foo:13, baz: 11});
76 | });
77 |
78 | it('multiple derivers deriving from one deriver', () => {
79 | const options = {
80 | oops({ack}) {
81 | return ack + this.foo() + this.bar();
82 | },
83 | foo({bar}) {
84 | return this.baz() + bar + 1;
85 | },
86 | bar({bar}) {
87 | return this.baz() + bar + 5;
88 | },
89 | baz({bar}) {
90 | return bar + 10;
91 | }
92 | };
93 | const derivedProps = deriveProps(options, {bar: 1, ack:100} )
94 | expect(derivedProps).toEqual({bar:17, foo:13, baz: 11, ack: 100, oops: 130});
95 | });
96 |
97 | it('should error when deriving causes loop', () => {
98 | const options = {
99 | foo({bar}) {
100 | return this.baz() + bar + 1;
101 | },
102 | baz({bar}) {
103 | return bar + 10 + this.foo();
104 | }
105 | };
106 | try {
107 | const derivedProps = deriveProps(options, {bar: 1} )
108 | } catch(e) {
109 | if (e.toString() !== "Error: Circular dependencies in derived props, 'baz' was blocked.")
110 | throw Error(`Circular error test failed (${e.toString()}`)
111 | }
112 | });
113 | });
114 |
115 | describe('track', () => {
116 | it('should track prop changes and only re-render as necessary', () => {
117 | let derivedProps;
118 | let renderCount = 0;
119 | let fooCount = 0;
120 |
121 | const options = {
122 | @track('bar')
123 | foo({bar}) {
124 | fooCount++;
125 | return bar+1;
126 | }
127 | };
128 |
129 | const Comp = createTestComponent(options, props => {
130 | renderCount++;
131 | derivedProps = props;
132 | });
133 | const Container = class Container extends Component {
134 | render() {
135 | const bar = this.state && this.state.bar;
136 | return bar ? : null;
137 | }
138 | }
139 |
140 | const container = TestUtils.renderIntoDocument( );
141 |
142 | container.setState({bar: 10});
143 | expect(fooCount).toEqual(1);
144 | expect(renderCount).toEqual(1);
145 | expect(derivedProps.foo).toEqual(11);
146 |
147 | container.setState({bar: 20});
148 | expect(fooCount).toEqual(2);
149 | expect(renderCount).toEqual(2);
150 | expect(derivedProps.foo).toEqual(21);
151 |
152 | container.setState({baz: 666});
153 | expect(fooCount).toEqual(2);
154 | expect(renderCount).toEqual(3);
155 | expect(derivedProps.foo).toEqual(21);
156 | })
157 | })
158 |
--------------------------------------------------------------------------------
/src/index.js:
--------------------------------------------------------------------------------
1 | import React, {Component} from 'react';
2 | const BLOCKED = {};
3 |
4 | export const globalOptions = {debug:false};
5 |
6 | /**
7 | * ## derive
8 | *
9 | * Create a derived data higher-order component (HoC).
10 | *
11 | * @param {Object} options (optional)
12 | * @param {Boolean} debug (optional)
13 | * @return {Object}
14 | */
15 | export function derive(options={}) {
16 | return DecoratedComponent => class DeriveDecorator extends Component {
17 | static displayName = `Derive(${getDisplayName(DecoratedComponent)})`;
18 | static DecoratedComponent = DecoratedComponent;
19 |
20 | componentWillMount() {
21 | this.derivedProps = deriveProps(options, {}, this.props, {});
22 | }
23 |
24 | componentWillUpdate(nextProps) {
25 | this.derivedProps = deriveProps(options, this.props, nextProps, this.derivedProps || {});
26 | }
27 |
28 | render() {
29 | return React.createElement(DecoratedComponent, this.derivedProps);
30 | }
31 | }
32 | }
33 |
34 | // `deriveProps` takes props from the previous render (`prevProps`, `derivedProps`),
35 | // and props from the current render (`nextProps`) and calculates the next derived props.
36 | function deriveProps(options, prevProps, nextProps, derivedProps) {
37 | const nextDerivedProps = {};
38 |
39 | const calcDerivedProp = (key, xf) => {
40 |
41 | // When `xf` is annotated with `trackedProps` (by `@track`), only re-calculate
42 | // derived props when the tracked props changed.
43 | if (xf.trackedProps && xf.trackedProps.every(p => prevProps[p] === nextProps[p])) {
44 | return derivedProps[key];
45 | }
46 |
47 | if (globalOptions.debug) console.log(`Recalculating derived prop '${key}'`);
48 | return xf.call(delegates, nextProps, derivedProps);
49 | };
50 |
51 | // `delegates` is the object that will be attached to the `this` Object
52 | // of deriver (`xf`) functions. (see `xf.call(delegates...)` above)
53 | const delegates =
54 | options::map((xf,key) =>
55 | () => {
56 | if (!nextDerivedProps.hasOwnProperty(key)) {
57 | nextDerivedProps[key] = BLOCKED;
58 | return nextDerivedProps[key] = calcDerivedProp(key, xf);
59 | } else {
60 | if (nextDerivedProps[key] === BLOCKED) {
61 | throw Error(`Circular dependencies in derived props, '${key}' was blocked.`)
62 | }
63 | return nextDerivedProps[key]
64 | }
65 | });
66 |
67 | Object.keys(options).forEach(key => {
68 | if (!nextDerivedProps.hasOwnProperty(key))
69 | // calculate derived prop
70 | nextDerivedProps[key] = calcDerivedProp(key, options[key]);
71 | });
72 |
73 | return {...nextProps, ...nextDerivedProps};
74 | }
75 |
76 | function getDisplayName (comp) {
77 | return comp.displayName || comp.name || 'Component';
78 | }
79 |
80 | // map an object to an object
81 | function map(f, result={}) {
82 | Object.keys(this).forEach(k => result[k] = f(this[k],k));
83 | return result;
84 | }
85 |
86 | /**
87 | * ## track
88 | *
89 | * Object literal decorator that annotates a mapper function
90 | * to have a 'trackedProps' property. Used by `@derive` to memoize
91 | * props.
92 | *
93 | * @param {String...} trackedProps
94 | * @return {Function}
95 | */
96 | export function track(...trackedProps) {
97 | return function(target, key, descriptor) {
98 | if (descriptor) { // ES7 decorator
99 | descriptor.value.trackedProps = trackedProps;
100 | } else { // ES6
101 | target.trackedProps = trackedProps;
102 | return target;
103 | }
104 | }
105 | }
106 |
107 | /**
108 | * ## Derive
109 | *
110 | * `@derive` as a component.
111 | * @prop {Object} options
112 | */
113 | export class Derive extends Component {
114 | componentWillMount() {
115 | this.derivedProps = deriveProps(this.props.options, {}, this.props, {});
116 | }
117 |
118 | componentWillUpdate(nextProps) {
119 | this.derivedProps = deriveProps(nextProps.options, this.props, nextProps, this.derivedProps || {});
120 | }
121 |
122 | render() {
123 | return React.Children.only(this.props.children(this.derivedProps));
124 | }
125 | }
126 |
--------------------------------------------------------------------------------
/webpack.config.js:
--------------------------------------------------------------------------------
1 | var webpack = require('webpack');
2 |
3 | var plugins = [
4 | new webpack.DefinePlugin({
5 | 'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV)
6 | })
7 | ];
8 |
9 | if (process.env.COMPRESS) {
10 | plugins.push(
11 | new webpack.optimize.UglifyJsPlugin({
12 | compressor: {
13 | warnings: false
14 | }
15 | })
16 | );
17 | }
18 |
19 | module.exports = {
20 |
21 | output: {
22 | library: 'ReactDerive',
23 | libraryTarget: 'umd'
24 | },
25 |
26 | externals: [
27 | {
28 | "react": {
29 | root: "React",
30 | commonjs2: "react",
31 | commonjs: "react",
32 | amd: "react"
33 | }
34 | }
35 | ],
36 |
37 | module: {
38 | loaders: [
39 | { test: /\.js$/, loader: 'babel-loader?stage=0' }
40 | ]
41 | },
42 |
43 | node: {
44 | Buffer: false
45 | },
46 |
47 | plugins: plugins
48 |
49 | };
50 |
--------------------------------------------------------------------------------