├── .babelrc ├── .gitignore ├── .npmignore ├── LICENSE ├── README.md ├── example ├── .babelrc ├── input.js └── output.js ├── package.json ├── src └── index.js └── test ├── fixtures ├── .babelrc ├── actual.js └── expected.js └── index.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["es2015"] 3 | } 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | lib 3 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | src 3 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Roman Liutikov 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # babel-plugin-stateful-functional-react-components 2 | 3 | ✨ _Stateful functional React components without runtime overhead (inspired by ClojureScript)_ ✨ 4 | 5 | _Compiles stateful functional React components syntax into ES2015 classes_ 6 | 7 | WARNING: _This plugin is experimental. If you are interested in taking this further, please open an issue or submit a PR with improvements._ 8 | 9 | [![npm](https://img.shields.io/npm/v/babel-plugin-stateful-functional-react-components.svg?style=flat-square)](https://www.npmjs.com/package/babel-plugin-stateful-functional-react-components) 10 | 11 | ## Table of Contents 12 | - [Why?](#why) 13 | - [Advantages](#advantages) 14 | - [Example](#example) 15 | - [API](#api) 16 | - [Important Notes](#important-notes) 17 | - [Installation](#installation) 18 | - [Usage](#usage) 19 | - [License](#license) 20 | 21 | ## Why? 22 | Because functional components are concise and it's annoying to write ES2015 classes when all you need is local state. 23 | 24 | ## Advantages 25 | - No runtime overhead 26 | - No dependencies that adds additional KB's to your bundle 27 | 28 | ## Example 29 | 30 | __Input__ 31 | ```js 32 | // props context state init state 33 | const Counter = ({ text }, { theme }, { val } = { val: 0 }, setState) => ( 34 |
35 |

{text}

36 |
37 | 38 | {val} 39 | 40 |
41 |
42 | ); 43 | ``` 44 | 45 | __Output__ 46 | ```js 47 | class Counter extends React.Component { 48 | constructor() { 49 | super(); 50 | this.state = { val: 0 }; 51 | } 52 | render() { 53 | 54 | const { text } = this.props; 55 | const { theme } = this.context; 56 | const { val } = this.state; 57 | 58 | return ( 59 |
60 |

{text}

61 |
62 | 63 | {val} 64 | 65 |
66 |
67 | ); 68 | } 69 | } 70 | ``` 71 | 72 | ## API 73 | 74 | **(props [,context], state = initialState, setState)** 75 | 76 | - `props` is component’s props i.e. `this.props` 77 | - `context` is optional parameter which corresponds to React’s context 78 | - `state` is component’s state, `initialState` is required 79 | - `setState` maps to `this.setState` 80 | 81 | ## Important notes 82 | - _state_ parameter _must_ be assigned default value (_initial state_) 83 | - The last parameter _must_ be named `setState` 84 | - Even though this syntax makes components look _functional_, don't forget that they are also _stateful_, which means that hot-reloading won't work for them. 85 | 86 | ## Installation 87 | ``` 88 | npm i babel-plugin-stateful-functional-react-components 89 | ``` 90 | 91 | ## Usage 92 | 93 | ### Via .babelrc (Recommended) 94 | 95 | __.babelrc__ 96 | ```json 97 | { 98 | "plugins": ["stateful-functional-react-components"] 99 | } 100 | ``` 101 | 102 | ### Via CLI 103 | ``` 104 | babel --plugins stateful-functional-react-components script.js 105 | ``` 106 | 107 | ### Via Node API 108 | ```js 109 | require("babel-core").transform("code", { 110 | plugins: ["stateful-functional-react-components"] 111 | }); 112 | ``` 113 | 114 | ## License 115 | MIT 116 | -------------------------------------------------------------------------------- /example/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": [ 3 | ["../lib"] 4 | ], 5 | "presets": ["react"] 6 | } 7 | -------------------------------------------------------------------------------- /example/input.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | const Counter = (props, { val } = { val: 0 }, setState) => ( 4 |
5 | 6 | {val} 7 | 8 |
9 | ); 10 | 11 | const App = ({ text }, { theme }, { val } = { val: '' }, setState) => { 12 | return ( 13 |
14 |

{text}

15 | setState({ val: e.target.value })} /> 16 | 17 |
18 | ); 19 | }; 20 | -------------------------------------------------------------------------------- /example/output.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | class Counter extends React.Component { 4 | constructor() { 5 | super(); 6 | this.state = { val: 0 }; 7 | } 8 | 9 | render() { 10 | const props = this.props; 11 | const { val } = this.state; 12 | return React.createElement( 13 | 'div', 14 | null, 15 | React.createElement( 16 | 'button', 17 | { onClick: () => this.setState({ val: val - 1 }) }, 18 | '-' 19 | ), 20 | React.createElement( 21 | 'span', 22 | null, 23 | val 24 | ), 25 | React.createElement( 26 | 'button', 27 | { onClick: () => this.setState({ val: val + 1 }) }, 28 | '+' 29 | ) 30 | ); 31 | } 32 | 33 | } 34 | 35 | class App extends React.Component { 36 | constructor() { 37 | super(); 38 | this.state = { val: '' }; 39 | } 40 | 41 | render() { 42 | const { text } = this.props; 43 | const { theme } = this.context; 44 | const { val } = this.state; 45 | 46 | return React.createElement( 47 | 'div', 48 | { className: theme }, 49 | React.createElement( 50 | 'h1', 51 | null, 52 | text 53 | ), 54 | React.createElement('input', { value: val, onChange: e => this.setState({ val: e.target.value }) }), 55 | React.createElement(Counter, null) 56 | ); 57 | } 58 | 59 | } 60 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "babel-plugin-stateful-functional-react-components", 3 | "version": "0.0.5", 4 | "description": "Stateful functional React components without runtime overhead", 5 | "repository": "roman01la/babel-plugin-stateful-functional-react-components", 6 | "main": "lib/index.js", 7 | "scripts": { 8 | "clean": "rm -rf lib", 9 | "build": "babel src -d lib", 10 | "example": "npm run build && babel example/input.js -o example/output.js", 11 | "test": "mocha --compilers js:babel-register", 12 | "test:watch": "npm run test -- --watch", 13 | "prepublish": "npm run clean && npm run build" 14 | }, 15 | "keywords": [ 16 | "babel", 17 | "plugin", 18 | "babel-plugin", 19 | "react", 20 | "stateful", 21 | "functional", 22 | "component" 23 | ], 24 | "author": "Roman Liutikov ", 25 | "license": "MIT", 26 | "devDependencies": { 27 | "babel-cli": "^6.24.1", 28 | "babel-preset-es2015": "^6.24.1", 29 | "babel-preset-react": "^6.24.1", 30 | "mocha": "^3.2.0" 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | function rewriteFunctionalComponent(t, path, isCtx) { 2 | 3 | const bodyPath = path.get('declarations.0.init.body'); 4 | const decl = path.node.declarations[0]; 5 | 6 | const className = decl.id; 7 | const propsRefs = decl.init.params[0]; 8 | const ctxRefs = isCtx ? decl.init.params[1] : null; 9 | const stateRefs = isCtx ? decl.init.params[2].left : decl.init.params[1].left; 10 | const stateVal = isCtx ? decl.init.params[2].right : decl.init.params[1].right; 11 | 12 | // replace `setState` with `this.setState` 13 | bodyPath.traverse({ 14 | CallExpression(path) { 15 | if (path.node.callee.name === 'setState') { 16 | path.replaceWith( 17 | t.CallExpression( 18 | t.MemberExpression( 19 | t.ThisExpression(), 20 | path.node.callee), 21 | path.node.arguments)); 22 | } 23 | } 24 | }); 25 | 26 | // get return value as a block statement 27 | const returnVal = t.isBlockStatement(bodyPath.node) 28 | ? bodyPath.node 29 | : t.BlockStatement([ 30 | t.ReturnStatement(bodyPath.node)]); 31 | 32 | // rewrite `state` declaration 33 | returnVal.body.unshift( 34 | t.VariableDeclaration( 35 | 'const', 36 | [t.VariableDeclarator( 37 | stateRefs, 38 | t.MemberExpression( 39 | t.ThisExpression(), 40 | t.Identifier('state')))])); 41 | 42 | // rewrite `context` declaration 43 | if (isCtx) { 44 | returnVal.body.unshift( 45 | t.VariableDeclaration( 46 | 'const', 47 | [t.VariableDeclarator( 48 | ctxRefs, 49 | t.MemberExpression( 50 | t.ThisExpression(), 51 | t.Identifier('context')))])); 52 | } 53 | 54 | // rewrite `props` declaration 55 | returnVal.body.unshift( 56 | t.VariableDeclaration( 57 | 'const', 58 | [t.VariableDeclarator( 59 | propsRefs, 60 | t.MemberExpression( 61 | t.ThisExpression(), 62 | t.Identifier('props')))])); 63 | 64 | // Ensure React is avaible in the global scope 65 | if (!path.scope.hasGlobal("React") && !path.scope.hasBinding("React")) { 66 | throw new Error( 67 | ` 68 | React was not found. 69 | 70 | You need to add this import on top of your file: 71 | import React from 'react' 72 | ` 73 | ); 74 | } 75 | 76 | // rewrite functional component into ES2015 class 77 | path.replaceWith( 78 | t.ClassDeclaration( 79 | className, 80 | t.MemberExpression( 81 | t.Identifier('React'), 82 | t.Identifier('Component')), 83 | t.ClassBody([ 84 | t.ClassMethod( 85 | 'constructor', 86 | t.Identifier('constructor'), 87 | [], 88 | t.BlockStatement([ 89 | t.ExpressionStatement( 90 | t.CallExpression( 91 | t.Super(), 92 | [])), 93 | t.ExpressionStatement( 94 | t.AssignmentExpression( 95 | '=', 96 | t.MemberExpression( 97 | t.ThisExpression(), 98 | t.Identifier('state')), 99 | stateVal))])), 100 | t.ClassMethod( 101 | 'method', 102 | t.Identifier('render'), 103 | [], 104 | returnVal)]), 105 | [])); 106 | } 107 | 108 | function isStatefulFn(t, init) { 109 | return ( 110 | t.isArrowFunctionExpression(init) && 111 | init.params.length === 3 && 112 | t.isAssignmentPattern(init.params[1]) && 113 | t.isObjectExpression(init.params[1].right) && 114 | init.params[2].name === 'setState' 115 | ); 116 | } 117 | 118 | function isStatefulFnWithContext(t, init) { 119 | return ( 120 | t.isArrowFunctionExpression(init) && 121 | init.params.length === 4 && 122 | t.isAssignmentPattern(init.params[2]) && 123 | t.isObjectExpression(init.params[2].right) && 124 | init.params[3].name === 'setState' 125 | ); 126 | } 127 | 128 | export default function (babel) { 129 | const { types: t } = babel; 130 | 131 | return { 132 | visitor: { 133 | VariableDeclaration(path) { 134 | 135 | const init = path.node.declarations[0].init; 136 | const isCtx = isStatefulFnWithContext(t, init); 137 | 138 | if (isStatefulFn(t, init) || isCtx) { 139 | rewriteFunctionalComponent(t, path, isCtx); 140 | } 141 | } 142 | } 143 | }; 144 | } 145 | -------------------------------------------------------------------------------- /test/fixtures/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": [ 3 | ["../../src"] 4 | ], 5 | "presets": ["react"] 6 | } 7 | -------------------------------------------------------------------------------- /test/fixtures/actual.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | const Counter = (props, state = { val: 0 }, setState) => { 4 | const { val } = state; 5 | const v = state.val; 6 | const vs = state['val']; 7 | const vxs = [state.val].map((n) => n + 1); 8 | const dec = () => setState({ val: --val }); 9 | return ( 10 |
11 | 12 | {state.val} 13 | 14 |
15 | ); 16 | }; 17 | 18 | const App = ({ text }, { theme }, { val } = { val: '' }, setState) => { 19 | return ( 20 |
21 |

{text}

22 | setState({ val: e.target.value })} /> 23 | 24 |
25 | ); 26 | }; 27 | -------------------------------------------------------------------------------- /test/fixtures/expected.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | class Counter extends React.Component { 4 | constructor() { 5 | super(); 6 | this.state = { val: 0 }; 7 | } 8 | 9 | render() { 10 | const props = this.props; 11 | const state = this.state; 12 | 13 | const { val } = state; 14 | const v = state.val; 15 | const vs = state['val']; 16 | const vxs = [state.val].map(n => n + 1); 17 | const dec = () => this.setState({ val: --val }); 18 | return React.createElement( 19 | 'div', 20 | null, 21 | React.createElement( 22 | 'button', 23 | { onClick: dec }, 24 | '-' 25 | ), 26 | React.createElement( 27 | 'span', 28 | null, 29 | state.val 30 | ), 31 | React.createElement( 32 | 'button', 33 | { onClick: () => this.setState({ val: ++val }) }, 34 | '+' 35 | ) 36 | ); 37 | } 38 | 39 | } 40 | 41 | class App extends React.Component { 42 | constructor() { 43 | super(); 44 | this.state = { val: '' }; 45 | } 46 | 47 | render() { 48 | const { text } = this.props; 49 | const { theme } = this.context; 50 | const { val } = this.state; 51 | 52 | return React.createElement( 53 | 'div', 54 | { className: theme }, 55 | React.createElement( 56 | 'h1', 57 | null, 58 | text 59 | ), 60 | React.createElement('input', { value: val, onChange: e => this.setState({ val: e.target.value }) }), 61 | React.createElement(Counter, null) 62 | ); 63 | } 64 | 65 | } 66 | -------------------------------------------------------------------------------- /test/index.js: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import fs from 'fs'; 3 | import assert from 'assert'; 4 | import { transformFileSync } from 'babel-core'; 5 | import plugin from '../src'; 6 | 7 | function trim(str) { 8 | return str.replace(/^\s+|\s+$/, ''); 9 | } 10 | 11 | describe('it', () => { 12 | 13 | const fixturesDir = path.join(__dirname, 'fixtures'); 14 | 15 | it('should transform stateful functional component into ES2015 class', () => { 16 | const actualPath = path.join(fixturesDir, 'actual.js'); 17 | const actual = transformFileSync(actualPath).code; 18 | 19 | const expected = fs.readFileSync( 20 | path.join(fixturesDir, 'expected.js') 21 | ).toString(); 22 | 23 | assert.equal(trim(actual), trim(expected)); 24 | }); 25 | }); 26 | --------------------------------------------------------------------------------