├── .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 | 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 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 | 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 | 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 | 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 | 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 | --------------------------------------------------------------------------------