├── .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 | [](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 | setState({ val: val - 1 })}>-
38 | {val}
39 | setState({ val: val + 1 })}>+
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 | this.setState({ val: val - 1 })}>-
63 | {val}
64 | this.setState({ val: val + 1 })}>+
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 | setState({ val: val - 1 })}>-
6 | {val}
7 | setState({ val: val + 1 })}>+
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 | setState({ val: ++val })}>+
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 |
--------------------------------------------------------------------------------