├── .eslintrc ├── .gitignore ├── .travis.yml ├── CONTRIBUTING.md ├── README.md ├── demo └── src │ ├── App.js │ ├── ConnectFiltering.js │ ├── components │ └── codeHighlight.js │ ├── index.js │ ├── main.css │ ├── reset.css │ └── utils │ ├── prism.css │ └── prism.js ├── nwb.config.js ├── package.json ├── src ├── Connect.js ├── Provider.js └── index.js ├── tests ├── .eslintrc └── index-test.js └── yarn.lock /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "react-tools" 3 | } 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /coverage 2 | /demo/dist 3 | /es 4 | /lib 5 | /node_modules 6 | /umd 7 | npm-debug.log* 8 | yarn-error.log 9 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: false 2 | 3 | language: node_js 4 | node_js: 5 | - 8 6 | 7 | before_install: 8 | - npm install codecov.io coveralls 9 | 10 | after_success: 11 | - cat ./coverage/lcov.info | ./node_modules/codecov.io/bin/codecov.io.js 12 | - cat ./coverage/lcov.info | ./node_modules/coveralls/bin/coveralls.js 13 | 14 | branches: 15 | only: 16 | - master 17 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | ## Prerequisites 2 | 3 | [Node.js](http://nodejs.org/) >= v4 must be installed. 4 | 5 | ## Installation 6 | 7 | - Running `npm install` in the component's root directory will install everything you need for development. 8 | 9 | ## Demo Development Server 10 | 11 | - `npm start` will run a development server with the component's demo app at [http://localhost:3000](http://localhost:3000) with hot module reloading. 12 | 13 | ## Running Tests 14 | 15 | - `npm test` will run the tests once. 16 | 17 | - `npm run test:coverage` will run the tests and produce a coverage report in `coverage/`. 18 | 19 | - `npm run test:watch` will run the tests on every change. 20 | 21 | ## Building 22 | 23 | - `npm run build` will build the component for publishing to npm and also bundle the demo app. 24 | 25 | - `npm run clean` will delete built resources. 26 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # [react-state](https://github.com/tannerlinsley/react-state) 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | Superpowers for managing local and reusable state in React 20 | 21 | ## Features 22 | 23 | * **2kb!** (minified) 24 | * No dependencies 25 | * Ditch repetitive props and callbacks throughout deeply nested components 26 | * Improved performance via Provider & Connector components 27 | * Testable and predictable 28 | 29 | ## [Demo](https://react-state.js.org/?selectedKind=2.%20Demos&selectedStory=Kitchen%20Sink&full=0&down=0&left=1&panelRight=0&downPanel=kadirahq%2Fstorybook-addon-actions%2Factions-panel) 30 | 31 | ## Table of Contents 32 | 33 | * [Installation](#installation) 34 | * [Example](#example) 35 | * [Provider](#provider) 36 | * [Creating a Provider](#creating-a-provider) 37 | * [Initial State](#initial-state) 38 | * [Passing Props as State](#passing-props-as-state) 39 | * [Programatic Control](#programatic-control) 40 | * [Connect](#connect) 41 | * [Memoization And Selectors](#memoization-and-selectors) 42 | * [Using the Dispatcher](#using-the-dispatch) 43 | * [Dispatch Meta](#dispatch-meta) 44 | * [Connect Config](#connect-config) 45 | 46 | ## Installation 47 | 48 | ```bash 49 | $ yarn add react-state 50 | ``` 51 | 52 | ## Example 53 | 54 | ```javascript 55 | import React from 'react' 56 | import { Provider, Connect } from 'react-state' 57 | 58 | 59 | const Count = ({ count }) => ({ 60 | {count} 61 | } 62 | // Use Connect to subscribe to values from the Provider state 63 | const ConnectedCount = Connect(state => ({ 64 | count: state.count 65 | }))(Count) 66 | 67 | // Every Connected component can use the 'dispatch' prop 68 | // to update the Provider's store 69 | const Increment = ({ dispatch }) => ({ 70 | 80 | } 81 | const ConnectedIncrement = Connect()(Increment) 82 | 83 | const Demo = () => ( 84 |
85 | 86 | 87 |
88 | ) 89 | 90 | // A Provider is a new instance of state for all nodes inside it 91 | export default Provider(Demo) 92 | ``` 93 | 94 | ## Provider 95 | 96 | The `Provider` higher-order component creates a new state that wraps the component you pass it. You can nest Providers inside each other, and when doing so, `Connect`ed components inside them will connect to the nearest parent Provider. You can also give Providers an initial state in an optional config object. 97 | 98 | ##### Creating a Provider 99 | 100 | ```javascript 101 | const ProviderWrappedComponent = Provider(MyComponent); 102 | ``` 103 | 104 | ##### Initial State 105 | 106 | ```javascript 107 | const ProviderWrappedComponent = Provider(MyComponent, { 108 | // the initial state of the provider 109 | initial: { 110 | foo: 1, 111 | bar: "hello" 112 | } 113 | }); 114 | ``` 115 | 116 | ##### Passing props as state 117 | 118 | Any props you pass to a Provider will be merged with the state and overwrite any same-key values. 119 | 120 | ```javascript 121 | 122 | ``` 123 | 124 | ##### Programatic Control 125 | 126 | If you ever need to programmatically dispatch to a provider, you can use a ref! 127 | 128 | ```javascript 129 | { 131 | provider.dispatch; 132 | }} 133 | /> 134 | ``` 135 | 136 | ## Connect 137 | 138 | The `Connect` higher-order component subscribes a component to any part of the nearest parent Provider, and also provides the component the `dispatch` prop for updating the state. 139 | 140 | ##### Subscribing to state 141 | 142 | To subscribe to a part of the provider state, we use a function that takes the state (and component props) and returns a new object with parts of the state you're interested in. Any time the values of that object change, your component will be updated! (There is no need to return props that already exist on your component. The 'props' argument is simply there as an aid in calculating the state you need to subscribe to) 143 | 144 | ```javascript 145 | class MyComponent extends Component { 146 | render() { 147 | return ( 148 |
149 | // This 'foo' prop comes from our Connect function below 150 |
{this.props.foo}
151 |
152 | ); 153 | } 154 | } 155 | const MyConnectedComponent = Connect(state => { 156 | return { 157 | foo: state.foo // Any time 'foo' changes, our component will update! 158 | }; 159 | }); 160 | ``` 161 | 162 | ##### Using the dispatcher 163 | 164 | Every connected component receives a 'dispatch' prop. You can use this 'dispatch' function to update the provider state. Just dispatch a function that takes the current state and returns a new version of the state. It's very important to make changes using immutability and also include any unchanged parts of the state. What you return will replace the entire state! 165 | 166 | ```javascript 167 | class MyComponent extends Component { 168 | render() { 169 | return ( 170 |
171 | 181 |
182 | ); 183 | } 184 | } 185 | const MyConnectedComponent = Connect()(MyComponent); 186 | ``` 187 | 188 | ##### Memoization and Selectors 189 | 190 | If you need to subscribe to computed or derived data, you can use a memoized selector. This functions exactly as it does in Redux. For more information, and examples on usage, please refer to [Redux - Computing Derived Data](http://redux.js.org/docs/recipes/ComputingDerivedData.html) 191 | 192 | ```javascript 193 | class MyComponent extends Component { 194 | render() { 195 | return ( 196 |
197 |
{this.props.computedValue}
198 |
199 | ); 200 | } 201 | } 202 | const MyConnectedComponent = Connect((state, props) => { 203 | return { 204 | computedValue: selectMyComputedValue(state, props) 205 | }; 206 | }); 207 | ``` 208 | 209 | ##### Dispatch Meta 210 | 211 | Any time you dispatch, you have the option to send through a meta object. This is useful for middlewares, hooks, and other optimization options throughout react-state. 212 | 213 | ```javascript 214 | class MyComponent extends Component { 215 | render () { 216 | return ( 217 |
218 | 229 |
230 | ) 231 | } 232 | } 233 | const MyConnectedComponent = Connect()(MyComponent) 234 | ``` 235 | 236 | ##### Connect Config 237 | 238 | `Connect` can be customized for performance and various other enhancments: 239 | 240 | * `pure: Boolean`: Defualts to `true`. When `true` the component will only rerender when the resulting props from the selector change in a shallow comparison. 241 | * `filter(oldState, newState, meta)`: Only run connect if this function returns true. Useful for avoiding high-velocity dispatches or general performance tuning. 242 | * `statics{}`: An object of static properties to add to the connected component's class. 243 | 244 | ```javascript 245 | class MyComponent extends Component { ... } 246 | const MyConnectedComponent = Connect( 247 | state => {...}, 248 | { 249 | // Using the following 'filter' function, this Connect will not run if 'meta.mySpecialValue === 'superSpecial' 250 | filter: (oldState, newState, meta) => { 251 | return meta.mySpecialValue ? meta.mySpecialValue !== 'superSpecial' : true 252 | }, 253 | 254 | // The Connected component class will also gain these statics 255 | statics: { 256 | defaultProps: { 257 | someProp: 'hello!' 258 | } 259 | } 260 | } 261 | )(MyComponent) 262 | ``` 263 | 264 | ## Contributing 265 | 266 | To suggest a feature, create an issue if it does not already exist. 267 | If you would like to help develop a suggested feature follow these steps: 268 | 269 | * Fork this repo 270 | * `$ yarn` 271 | * `$ yarn run storybook` 272 | * Implement your changes to files in the `src/` directory 273 | * View changes as you code via our React Storybook `localhost:8000` 274 | * Make changes to stories in `/stories`, or create a new one if needed 275 | * Submit PR for review 276 | 277 | #### Scripts 278 | 279 | * `$ yarn run storybook` Runs the storybook server 280 | * `$ yarn run test` Runs the test suite 281 | * `$ yarn run prepublish` Builds for NPM distribution 282 | * `$ yarn run docs` Builds the website/docs from the storybook for github pages 283 | 284 | 289 | -------------------------------------------------------------------------------- /demo/src/App.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | // 3 | import { Provider, Connect } from '../../src' 4 | 5 | const CodeHighlight = ({ children }) => ( 6 |
  7 |     {children()}
  8 |   
9 | ) 10 | 11 | const boxStyle = { 12 | margin: '10px', 13 | borderRadius: '5px', 14 | padding: '10px', 15 | color: 'white', 16 | transition: 'all .5s ease', 17 | fontWeight: 'bolder', 18 | textShadow: '0 0 10px black', 19 | } 20 | 21 | // ################ 22 | // FooComponent 23 | // ################ 24 | 25 | // FooComponent is pretty simple. It subscribes to, 26 | // and displays the 'foo' value from the provider. 27 | class FooComponent extends Component { 28 | render () { 29 | const { foo, children } = this.props 30 | return ( 31 |
37 |
Foo: {foo}
38 | {children} 39 |
40 | ) 41 | } 42 | } 43 | // FooComponent needs access to 'foo', so we'll subscribe to it. 44 | const ConnectedFooComponent = Connect(state => ({ 45 | foo: state.foo, 46 | }))(FooComponent) 47 | 48 | // ################ 49 | // BarComponent 50 | // ################ 51 | 52 | // BarComponent is pretty simple. It subscribes to, 53 | // and displays the 'bar' value from the provider. 54 | class BarComponent extends Component { 55 | render () { 56 | const { bar, children } = this.props 57 | return ( 58 |
64 |
Bar: {bar}
65 | {children} 66 |
67 | ) 68 | } 69 | } 70 | // BarComponent needs access to 'bar', so we'll subscribe to it. 71 | const ConnectedBarComponent = Connect(state => ({ 72 | bar: state.bar, 73 | }))(BarComponent) 74 | 75 | // ################ 76 | // FooBarComponent 77 | // ################ 78 | 79 | // FooBarComponent is very similar. 80 | // It displays both the foo' and 'bar' props 81 | class FooBarComponent extends Component { 82 | render () { 83 | const { foo, bar } = this.props 84 | return ( 85 |
91 |
Foo: {foo}
92 |
Bar: {bar}
93 |
94 | ) 95 | } 96 | } 97 | // FooBarComponent needs access to 'foo' and 'bar', so we'll subscribe to both of them. 98 | const ConnectedFooBarComponent = Connect(state => ({ 99 | foo: state.foo, 100 | bar: state.bar, 101 | }))(FooBarComponent) 102 | // Now, any time the 'foo' or 'bar' values change, FooBarComponent will rerender :) 103 | 104 | // ################ 105 | // BazComponent 106 | // ################ 107 | 108 | // The BazComponent shows whether the 'baz' value is 109 | // greater than 5 and also displays a message. 110 | class BazComponent extends Component { 111 | render () { 112 | const { bazIsFourth, message } = this.props 113 | return ( 114 |
120 |
Baz is a multiple of 4: {bazIsFourth.toString()}
121 |
Message: {message}
122 |
123 | ) 124 | } 125 | } 126 | // This time, we are going to return a calculated value. 127 | const ConnectedBazComponent = Connect(state => ({ 128 | bazIsFourth: state.baz % 4 === 0, 129 | // Since 'bazIsFourth' will be a boolean, we don't need to use a memoized value, 130 | // But if it was a non-primitive, a selector or memoized value is 131 | // recommended for performance. For an excellent solution, visit https://github.com/reactjs/reselect 132 | }))(BazComponent) 133 | // Now, our BazComponent will only update when the bazIsFourth value changes! 134 | 135 | // ################ 136 | // ControlComponent 137 | // ################ 138 | 139 | // ControlComponent contains a few buttons that will change 140 | // different parts of our state. 141 | class ControlComponent extends Component { 142 | render () { 143 | const { 144 | dispatch, // this callback is provided to every Connected component 145 | } = this.props 146 | return ( 147 |
153 |
154 | Foo:   155 | 169 | 179 |
180 |
181 | Bar:   182 | 192 | 202 |
203 |
204 | Baz:   205 | 215 | 225 |
226 |
227 | ) 228 | } 229 | } 230 | // The control component doesn't depend on any state, but 231 | // we still Connect it se we can use the 'dispatch' prop 232 | const ConnectedControlComponent = Connect()(ControlComponent) 233 | 234 | // ################ 235 | // Reusable Component 236 | // ################ 237 | 238 | // Now let's create our our reusable component. 239 | // We need to keep track of state in our component, 240 | // and your first instinct might be to use local state 241 | // to accomplish this. Interestingly enough though, 242 | // we would probably end up passing many pieces of the state 243 | // down to child components via props and, likewise, would need to 244 | // pass callbacks with them so our child components could 245 | // update the state. 246 | 247 | // We need a better state management system for our component than 248 | // local state, but nothing that will require including a state 249 | // manager like redux or MobX. 250 | 251 | // This is where Provider comes in! 252 | 253 | // Provider is used as a higher order component or decorator 254 | class MyAwesomeReusableComponent extends Component { 255 | render () { 256 | // Components that are wrapped with Provider automatically 257 | // receive the entire provider state as props. 258 | return ( 259 |
265 | Current Props: 266 |
267 | 268 | 269 | 272 | 273 | 274 | 275 | 276 |
277 | ) 278 | } 279 | } 280 | // Just pass Provider a component you would like to wrap and an optional config object 281 | // In the config, we can supply an 'initial' state for the Provider 282 | const ProvidedMyAwesomeReusableComponent = Provider(MyAwesomeReusableComponent, { 283 | initial: { 284 | baz: 3, 285 | }, 286 | }) 287 | 288 | export default class App extends Component { 289 | constructor () { 290 | super() 291 | this.state = getRandomFooBar() 292 | } 293 | render () { 294 | const { foo, bar } = this.state 295 | return ( 296 | // Let's use our awesome reusable component with some props! 297 |
298 | To aid in visualizing performance, each of our components has a background that changes 299 | every time it rerenders. 300 |
301 |
302 | Initial state for MyAwesomeReusableComponent: 303 |
304 |
305 | 306 | {() => 307 | JSON.stringify( 308 | { 309 | baz: 3, 310 | }, 311 | null, 312 | 2 313 | ) 314 | } 315 | 316 |
317 | Current state given to MyAwesomeReusableComponent: 318 |
319 |
320 | {() => JSON.stringify(this.state, null, 2)} 321 |
322 | 323 |
324 | 330 |
331 | ) 332 | } 333 | } 334 | 335 | function getRandomFooBar () { 336 | return { 337 | foo: Math.ceil(Math.random() * 10), 338 | bar: Math.ceil(Math.random() * 5), 339 | } 340 | } 341 | 342 | function makeRandomColor () { 343 | return `rgb(${Math.round(Math.random() * 255)}, ${Math.round(Math.random() * 255)}, ${Math.round( 344 | Math.random() * 255 345 | )})` 346 | } 347 | -------------------------------------------------------------------------------- /demo/src/ConnectFiltering.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | // 3 | import { Provider, Connect } from '../src' 4 | import sourceTxt from '!raw-loader!./ConnectFiltering.js' 5 | // 6 | import CodeHighlight from './components/codeHighlight.js' 7 | 8 | const boxStyle = { 9 | margin: '10px', 10 | borderRadius: '5px', 11 | padding: '10px', 12 | color: 'white', 13 | fontWeight: 'bolder', 14 | textShadow: '0 0 10px black' 15 | } 16 | 17 | class ExpensiveValue extends Component { 18 | render () { 19 | return ( 20 |
24 |
ExpensiveValue: {JSON.stringify(this.props.foo)}
25 |
26 | ) 27 | } 28 | } 29 | 30 | class Position extends Component { 31 | render () { 32 | return ( 33 |
37 |
Position: {JSON.stringify(this.props.position)}
38 |
39 | ) 40 | } 41 | } 42 | 43 | class Hover extends Component { 44 | render () { 45 | return ( 46 |
this.props.dispatch(state => ({ 53 | ...state, 54 | position: { 55 | x: e.clientX, 56 | y: e.clientY 57 | } 58 | }), { 59 | type: 'fromCursor' 60 | })} 61 | > 62 | Open your console and move your mouse around in here. 63 |
64 | See how the only connect function that runs is the "Position" component? 65 |
66 | ) 67 | } 68 | } 69 | 70 | class ChangeExpensiveValue extends Component { 71 | render () { 72 | return ( 73 |
77 |
78 | 86 |
87 |
88 | ) 89 | } 90 | } 91 | 92 | const ConnectedExpensiveValue = Connect(state => { 93 | console.log('Running connect for "ExpensiveValue" component') 94 | // This is to simulate a lot of heavy connect functions 95 | let foo = 0 96 | for (var i = 0; i < 1000000000; i++) { 97 | foo = i 98 | } 99 | foo = state.foo 100 | return { 101 | foo: foo 102 | } 103 | }, { 104 | filter: (oldState, newState, meta) => meta.type !== 'fromCursor' 105 | })(ExpensiveValue) 106 | 107 | const ConnectedPosition = Connect(state => { 108 | console.log('Running connect for "Position" component') 109 | return { 110 | position: state.position 111 | } 112 | })(Position) 113 | 114 | const ConnectedHover = Connect()(Hover) 115 | const ConnectedChangeExpensiveValue = Connect()(ChangeExpensiveValue) 116 | 117 | class ConnectFiltering extends Component { 118 | render () { 119 | return ( 120 |
121 | react-state is very good at change detection because of the way it can compare old and new states using Connect functions. But, whenever you dispatch an state change to your provider, EVERY connected component's subscribe function will run. 122 |
123 |
124 | If you are dispatching extremly rapidly, or having thousands of connected components, this can be extremely expensive, especially if you know beforehand that the subscribe function shouldn't even run! 125 |
126 |
127 | 128 |
129 | Now change ExpensiveValue to something random! 130 | 131 | Notice how all of the connect functions ran this time! 132 | 133 | 134 | This is because of a special "filter" option we placed on the "ExpensiveValue" components' connect function: 135 | {() => ` 136 | const ConnectedExpensiveValue = Connect(state => { 137 | console.log('Running connect for "ExpensiveValue" component') 138 | // This is to simulate a lot of expensive connect functions 139 | let foo = 0 140 | for (var i = 0; i < 1000000000; i++) { 141 | foo = i 142 | } 143 | foo = state.foo 144 | return { 145 | foo: foo 146 | } 147 | }, { 148 | // This filter function guarantees that it will only if the meta of type does not equal 'fromCursor'. 149 | filter: (oldState, newState, meta) => meta.type !== 'fromCursor' 150 | })(ExpensiveValue) 151 | 152 | // Then, in our cursor move dispatcher we can include 'fromCursor' in the meta: 153 | this.props.dispatch(state => ({ 154 | ...state, 155 | position: { 156 | x: e.clientX, 157 | y: e.clientY 158 | } 159 | }), { 160 | // This is the meta object 161 | type: 'fromCursor' 162 | }) 163 | `} 164 |
165 | ) 166 | } 167 | } 168 | // Just pass Provider a component you would like to wrap and an optional config object 169 | // In the config, we can supply an 'initial' state for the Provider 170 | const ProvidedConnectFiltering = Provider(ConnectFiltering, { 171 | initial: { 172 | foo: 1 173 | } 174 | }) 175 | 176 | class Demo extends Component { 177 | render () { 178 | return ( 179 | // Let's use our awesome reusable component with some props! 180 |
181 | 182 |
183 |
184 | Here is the full source of the example: 185 | {() => sourceTxt} 186 |
187 | ) 188 | } 189 | } 190 | 191 | export default () => 192 | 193 | function makeRandomColor () { 194 | return `rgb(${Math.round(Math.random() * 255)}, ${Math.round(Math.random() * 255)}, ${Math.round(Math.random() * 255)})` 195 | } 196 | -------------------------------------------------------------------------------- /demo/src/components/codeHighlight.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import '../utils/prism' 3 | 4 | export default React.createClass({ 5 | render () { 6 | const { language, children } = this.props 7 | return ( 8 |
 9 |         
10 |           {children()}
11 |         
12 |       
13 | ) 14 | }, 15 | componentDidMount () { 16 | window.Prism.highlightAll() 17 | }, 18 | componentDidUpdate () { 19 | window.Prism.highlightAll() 20 | } 21 | }) 22 | -------------------------------------------------------------------------------- /demo/src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { render } from 'react-dom' 3 | 4 | import './reset.css' 5 | import './main.css' 6 | 7 | import App from './App' 8 | 9 | render(, document.querySelector('#demo')) 10 | -------------------------------------------------------------------------------- /demo/src/main.css: -------------------------------------------------------------------------------- 1 | @import url("https://fonts.googleapis.com/css?family=Open+Sans:300,600"); 2 | html, 3 | body { 4 | background: #fff; 5 | font-family: "Open Sans", sans-serif; 6 | font-weight: 300; 7 | } 8 | 9 | html { 10 | min-height: 100vh; 11 | } 12 | body { 13 | background: #fff; 14 | font-family: "Open Sans", sans-serif; 15 | font-weight: 300; 16 | font-size: 14px; 17 | padding: 20px; 18 | } 19 | h1 { 20 | font-size: 2.5em; 21 | } 22 | a { 23 | color: #02bfe6; 24 | font-weight: bold; 25 | text-decoration: none; 26 | } 27 | strong { 28 | font-weight: bold; 29 | } 30 | .logo { 31 | width: 400px; 32 | max-width: 100%; 33 | margin: 0 auto; 34 | display: block; 35 | } 36 | 37 | .FormInput { 38 | display: inline-block; 39 | } 40 | form > div { 41 | margin-bottom: 1em; 42 | } 43 | h6 { 44 | font-size: 0.8em; 45 | font-weight: bold; 46 | margin-bottom: 0.5em; 47 | } 48 | label { 49 | display: block; 50 | } 51 | radiogroup { 52 | padding: 10px; 53 | border: 1px solid rgba(0, 0, 0, 0.1); 54 | display: inline-block; 55 | border-radius: 3px; 56 | } 57 | input, 58 | textarea, 59 | select { 60 | font-size: 15px; 61 | padding: 5px; 62 | border-radius: 3px; 63 | border: 1px solid rgba(0, 0, 0, 0.2); 64 | margin-bottom: 2px; 65 | } 66 | .FormInput.-error input, 67 | .FormInput.-error textarea, 68 | .FormInput.-error select { 69 | border-color: #f00; 70 | } 71 | .FormError { 72 | color: #f00; 73 | font-size: 12px; 74 | font-weight: bold; 75 | margin: 5px; 76 | } 77 | em { 78 | font-style: italic; 79 | } 80 | .nested { 81 | padding: 10px; 82 | } 83 | .nested > div { 84 | background: rgba(0, 0, 0, 0.05); 85 | border-radius: 5px; 86 | margin-bottom: 5px; 87 | padding: 10px; 88 | } 89 | .form_wrapper { 90 | padding: 20px; 91 | } 92 | 93 | .react-resizable { 94 | max-width: 100%; 95 | } 96 | -------------------------------------------------------------------------------- /demo/src/reset.css: -------------------------------------------------------------------------------- 1 | /* http://meyerweb.com/eric/tools/css/reset/ 2 | v2.0 | 20110126 3 | License: none (public domain) 4 | */ 5 | 6 | html, body, div, span, applet, object, iframe, 7 | h1, h2, h3, h4, h5, h6, p, blockquote, pre, 8 | a, abbr, acronym, address, big, cite, code, 9 | del, dfn, em, img, ins, kbd, q, s, samp, 10 | small, strike, strong, sub, sup, tt, var, 11 | b, u, i, center, 12 | dl, dt, dd, ol, ul, li, 13 | fieldset, form, label, legend, 14 | table, caption, tbody, tfoot, thead, tr, th, td, 15 | article, aside, canvas, details, embed, 16 | figure, figcaption, footer, header, hgroup, 17 | menu, nav, output, ruby, section, summary, 18 | time, mark, audio, video { 19 | margin: 0; 20 | padding: 0; 21 | border: 0; 22 | font-size: 100%; 23 | font: inherit; 24 | vertical-align: baseline; 25 | } 26 | /* HTML5 display-role reset for older browsers */ 27 | article, aside, details, figcaption, figure, 28 | footer, header, hgroup, menu, nav, section { 29 | display: block; 30 | } 31 | body { 32 | line-height: 1; 33 | } 34 | /*ol, ul { 35 | list-style: none; 36 | }*/ 37 | blockquote, q { 38 | quotes: none; 39 | } 40 | blockquote:before, blockquote:after, 41 | q:before, q:after { 42 | content: ''; 43 | content: none; 44 | } 45 | table { 46 | border-collapse: collapse; 47 | border-spacing: 0; 48 | } 49 | -------------------------------------------------------------------------------- /demo/src/utils/prism.css: -------------------------------------------------------------------------------- 1 | /* http://prismjs.com/download.html?themes=prism&languages=markup+css+clike+javascript */ 2 | /** 3 | * prism.js default theme for JavaScript, CSS and HTML 4 | * Based on dabblet (http://dabblet.com) 5 | * @author Lea Verou 6 | */ 7 | 8 | code[class*="language-"], 9 | pre[class*="language-"] { 10 | color: black; 11 | background: none; 12 | text-shadow: 0 1px white; 13 | font-family: Consolas, Monaco, 'Andale Mono', 'Ubuntu Mono', monospace; 14 | text-align: left; 15 | white-space: pre; 16 | word-spacing: normal; 17 | word-break: normal; 18 | word-wrap: normal; 19 | line-height: 1.5; 20 | 21 | -moz-tab-size: 4; 22 | -o-tab-size: 4; 23 | tab-size: 4; 24 | 25 | -webkit-hyphens: none; 26 | -moz-hyphens: none; 27 | -ms-hyphens: none; 28 | hyphens: none; 29 | } 30 | 31 | pre[class*="language-"]::-moz-selection, pre[class*="language-"] ::-moz-selection, 32 | code[class*="language-"]::-moz-selection, code[class*="language-"] ::-moz-selection { 33 | text-shadow: none; 34 | background: #b3d4fc; 35 | } 36 | 37 | pre[class*="language-"]::selection, pre[class*="language-"] ::selection, 38 | code[class*="language-"]::selection, code[class*="language-"] ::selection { 39 | text-shadow: none; 40 | background: #b3d4fc; 41 | } 42 | 43 | @media print { 44 | code[class*="language-"], 45 | pre[class*="language-"] { 46 | text-shadow: none; 47 | } 48 | } 49 | 50 | /* Code blocks */ 51 | pre[class*="language-"] { 52 | padding: 1em; 53 | margin: .5em 0; 54 | overflow: auto; 55 | } 56 | 57 | :not(pre) > code[class*="language-"], 58 | pre[class*="language-"] { 59 | background: #f5f2f0; 60 | } 61 | 62 | /* Inline code */ 63 | :not(pre) > code[class*="language-"] { 64 | padding: .1em; 65 | border-radius: .3em; 66 | white-space: normal; 67 | } 68 | 69 | .token.comment, 70 | .token.prolog, 71 | .token.doctype, 72 | .token.cdata { 73 | color: slategray; 74 | } 75 | 76 | .token.punctuation { 77 | color: #999; 78 | } 79 | 80 | .namespace { 81 | opacity: .7; 82 | } 83 | 84 | .token.property, 85 | .token.tag, 86 | .token.boolean, 87 | .token.number, 88 | .token.constant, 89 | .token.symbol, 90 | .token.deleted { 91 | color: #905; 92 | } 93 | 94 | .token.selector, 95 | .token.attr-name, 96 | .token.string, 97 | .token.char, 98 | .token.builtin, 99 | .token.inserted { 100 | color: #690; 101 | } 102 | 103 | .token.operator, 104 | .token.entity, 105 | .token.url, 106 | .language-css .token.string, 107 | .style .token.string { 108 | color: #a67f59; 109 | background: hsla(0, 0%, 100%, .5); 110 | } 111 | 112 | .token.atrule, 113 | .token.attr-value, 114 | .token.keyword { 115 | color: #07a; 116 | } 117 | 118 | .token.function { 119 | color: #DD4A68; 120 | } 121 | 122 | .token.regex, 123 | .token.important, 124 | .token.variable { 125 | color: #e90; 126 | } 127 | 128 | .token.important, 129 | .token.bold { 130 | font-weight: bold; 131 | } 132 | .token.italic { 133 | font-style: italic; 134 | } 135 | 136 | .token.entity { 137 | cursor: help; 138 | } 139 | -------------------------------------------------------------------------------- /demo/src/utils/prism.js: -------------------------------------------------------------------------------- 1 | /* http://prismjs.com/download.html?themes=prism&languages=markup+css+clike+javascript+jsx&plugins=line-highlight+line-numbers */ 2 | var _self="undefined"!=typeof window?window:"undefined"!=typeof WorkerGlobalScope&&self instanceof WorkerGlobalScope?self:{},Prism=function(){var e=/\blang(?:uage)?-(\w+)\b/i,t=0,n=_self.Prism={util:{encode:function(e){return e instanceof a?new a(e.type,n.util.encode(e.content),e.alias):"Array"===n.util.type(e)?e.map(n.util.encode):e.replace(/&/g,"&").replace(/e.length)break e;if(!(v instanceof a)){u.lastIndex=0;var b=u.exec(v),k=1;if(!b&&h&&m!=r.length-1){if(u.lastIndex=y,b=u.exec(e),!b)break;for(var w=b.index+(c?b[1].length:0),_=b.index+b[0].length,A=m,P=y,j=r.length;j>A&&_>P;++A)P+=r[A].length,w>=P&&(++m,y=P);if(r[m]instanceof a||r[A-1].greedy)continue;k=A-m,v=e.slice(y,P),b.index-=y}if(b){c&&(f=b[1].length);var w=b.index+f,b=b[0].slice(f),_=w+b.length,x=v.slice(0,w),O=v.slice(_),S=[m,k];x&&S.push(x);var N=new a(l,g?n.tokenize(b,g):b,d,b,h);S.push(N),O&&S.push(O),Array.prototype.splice.apply(r,S)}}}}}return r},hooks:{all:{},add:function(e,t){var a=n.hooks.all;a[e]=a[e]||[],a[e].push(t)},run:function(e,t){var a=n.hooks.all[e];if(a&&a.length)for(var r,i=0;r=a[i++];)r(t)}}},a=n.Token=function(e,t,n,a,r){this.type=e,this.content=t,this.alias=n,this.length=0|(a||"").length,this.greedy=!!r};if(a.stringify=function(e,t,r){if("string"==typeof e)return e;if("Array"===n.util.type(e))return e.map(function(n){return a.stringify(n,t,e)}).join("");var i={type:e.type,content:a.stringify(e.content,t,r),tag:"span",classes:["token",e.type],attributes:{},language:t,parent:r};if("comment"==i.type&&(i.attributes.spellcheck="true"),e.alias){var l="Array"===n.util.type(e.alias)?e.alias:[e.alias];Array.prototype.push.apply(i.classes,l)}n.hooks.run("wrap",i);var o=Object.keys(i.attributes).map(function(e){return e+'="'+(i.attributes[e]||"").replace(/"/g,""")+'"'}).join(" ");return"<"+i.tag+' class="'+i.classes.join(" ")+'"'+(o?" "+o:"")+">"+i.content+""},!_self.document)return _self.addEventListener?(_self.addEventListener("message",function(e){var t=JSON.parse(e.data),a=t.language,r=t.code,i=t.immediateClose;_self.postMessage(n.highlight(r,n.languages[a],a)),i&&_self.close()},!1),_self.Prism):_self.Prism;var r=document.currentScript||[].slice.call(document.getElementsByTagName("script")).pop();return r&&(n.filename=r.src,document.addEventListener&&!r.hasAttribute("data-manual")&&("loading"!==document.readyState?window.requestAnimationFrame?window.requestAnimationFrame(n.highlightAll):window.setTimeout(n.highlightAll,16):document.addEventListener("DOMContentLoaded",n.highlightAll))),_self.Prism}();"undefined"!=typeof module&&module.exports&&(module.exports=Prism),"undefined"!=typeof global&&(global.Prism=Prism); 3 | Prism.languages.markup={comment://,prolog:/<\?[\w\W]+?\?>/,doctype://i,cdata://i,tag:{pattern:/<\/?(?!\d)[^\s>\/=$<]+(?:\s+[^\s>\/=]+(?:=(?:("|')(?:\\\1|\\?(?!\1)[\w\W])*\1|[^\s'">=]+))?)*\s*\/?>/i,inside:{tag:{pattern:/^<\/?[^\s>\/]+/i,inside:{punctuation:/^<\/?/,namespace:/^[^\s>\/:]+:/}},"attr-value":{pattern:/=(?:('|")[\w\W]*?(\1)|[^\s>]+)/i,inside:{punctuation:/[=>"']/}},punctuation:/\/?>/,"attr-name":{pattern:/[^\s>\/]+/,inside:{namespace:/^[^\s>\/:]+:/}}}},entity:/&#?[\da-z]{1,8};/i},Prism.hooks.add("wrap",function(a){"entity"===a.type&&(a.attributes.title=a.content.replace(/&/,"&"))}),Prism.languages.xml=Prism.languages.markup,Prism.languages.html=Prism.languages.markup,Prism.languages.mathml=Prism.languages.markup,Prism.languages.svg=Prism.languages.markup; 4 | Prism.languages.css={comment:/\/\*[\w\W]*?\*\//,atrule:{pattern:/@[\w-]+?.*?(;|(?=\s*\{))/i,inside:{rule:/@[\w-]+/}},url:/url\((?:(["'])(\\(?:\r\n|[\w\W])|(?!\1)[^\\\r\n])*\1|.*?)\)/i,selector:/[^\{\}\s][^\{\};]*?(?=\s*\{)/,string:{pattern:/("|')(\\(?:\r\n|[\w\W])|(?!\1)[^\\\r\n])*\1/,greedy:!0},property:/(\b|\B)[\w-]+(?=\s*:)/i,important:/\B!important\b/i,"function":/[-a-z0-9]+(?=\()/i,punctuation:/[(){};:]/},Prism.languages.css.atrule.inside.rest=Prism.util.clone(Prism.languages.css),Prism.languages.markup&&(Prism.languages.insertBefore("markup","tag",{style:{pattern:/()[\w\W]*?(?=<\/style>)/i,lookbehind:!0,inside:Prism.languages.css,alias:"language-css"}}),Prism.languages.insertBefore("inside","attr-value",{"style-attr":{pattern:/\s*style=("|').*?\1/i,inside:{"attr-name":{pattern:/^\s*style/i,inside:Prism.languages.markup.tag.inside},punctuation:/^\s*=\s*['"]|['"]\s*$/,"attr-value":{pattern:/.+/i,inside:Prism.languages.css}},alias:"language-css"}},Prism.languages.markup.tag)); 5 | Prism.languages.clike={comment:[{pattern:/(^|[^\\])\/\*[\w\W]*?\*\//,lookbehind:!0},{pattern:/(^|[^\\:])\/\/.*/,lookbehind:!0}],string:{pattern:/(["'])(\\(?:\r\n|[\s\S])|(?!\1)[^\\\r\n])*\1/,greedy:!0},"class-name":{pattern:/((?:\b(?:class|interface|extends|implements|trait|instanceof|new)\s+)|(?:catch\s+\())[a-z0-9_\.\\]+/i,lookbehind:!0,inside:{punctuation:/(\.|\\)/}},keyword:/\b(if|else|while|do|for|return|in|instanceof|function|new|try|throw|catch|finally|null|break|continue)\b/,"boolean":/\b(true|false)\b/,"function":/[a-z0-9_]+(?=\()/i,number:/\b-?(?:0x[\da-f]+|\d*\.?\d+(?:e[+-]?\d+)?)\b/i,operator:/--?|\+\+?|!=?=?|<=?|>=?|==?=?|&&?|\|\|?|\?|\*|\/|~|\^|%/,punctuation:/[{}[\];(),.:]/}; 6 | Prism.languages.javascript=Prism.languages.extend("clike",{keyword:/\b(as|async|await|break|case|catch|class|const|continue|debugger|default|delete|do|else|enum|export|extends|finally|for|from|function|get|if|implements|import|in|instanceof|interface|let|new|null|of|package|private|protected|public|return|set|static|super|switch|this|throw|try|typeof|var|void|while|with|yield)\b/,number:/\b-?(0x[\dA-Fa-f]+|0b[01]+|0o[0-7]+|\d*\.?\d+([Ee][+-]?\d+)?|NaN|Infinity)\b/,"function":/[_$a-zA-Z\xA0-\uFFFF][_$a-zA-Z0-9\xA0-\uFFFF]*(?=\()/i,operator:/--?|\+\+?|!=?=?|<=?|>=?|==?=?|&&?|\|\|?|\?|\*\*?|\/|~|\^|%|\.{3}/}),Prism.languages.insertBefore("javascript","keyword",{regex:{pattern:/(^|[^\/])\/(?!\/)(\[.+?]|\\.|[^\/\\\r\n])+\/[gimyu]{0,5}(?=\s*($|[\r\n,.;})]))/,lookbehind:!0,greedy:!0}}),Prism.languages.insertBefore("javascript","string",{"template-string":{pattern:/`(?:\\\\|\\?[^\\])*?`/,greedy:!0,inside:{interpolation:{pattern:/\$\{[^}]+\}/,inside:{"interpolation-punctuation":{pattern:/^\$\{|\}$/,alias:"punctuation"},rest:Prism.languages.javascript}},string:/[\s\S]+/}}}),Prism.languages.markup&&Prism.languages.insertBefore("markup","tag",{script:{pattern:/()[\w\W]*?(?=<\/script>)/i,lookbehind:!0,inside:Prism.languages.javascript,alias:"language-javascript"}}),Prism.languages.js=Prism.languages.javascript; 7 | !function(a){var e=a.util.clone(a.languages.javascript);a.languages.jsx=a.languages.extend("markup",e),a.languages.jsx.tag.pattern=/<\/?[\w\.:-]+\s*(?:\s+[\w\.:-]+(?:=(?:("|')(\\?[\w\W])*?\1|[^\s'">=]+|(\{[\w\W]*?\})))?\s*)*\/?>/i,a.languages.jsx.tag.inside["attr-value"].pattern=/=[^\{](?:('|")[\w\W]*?(\1)|[^\s>]+)/i;var s=a.util.clone(a.languages.jsx);delete s.punctuation,s=a.languages.insertBefore("jsx","operator",{punctuation:/=(?={)|[{}[\];(),.:]/},{jsx:s}),a.languages.insertBefore("inside","attr-value",{script:{pattern:/=(\{(?:\{[^}]*\}|[^}])+\})/i,inside:s,alias:"language-javascript"}},a.languages.jsx.tag)}(Prism); 8 | !function(){function e(e,t){return Array.prototype.slice.call((t||document).querySelectorAll(e))}function t(e,t){return t=" "+t+" ",(" "+e.className+" ").replace(/[\n\t]/g," ").indexOf(t)>-1}function n(e,n,i){for(var o,a=n.replace(/\s+/g,"").split(","),l=+e.getAttribute("data-line-offset")||0,d=r()?parseInt:parseFloat,c=d(getComputedStyle(e).lineHeight),s=0;o=a[s++];){o=o.split("-");var u=+o[0],m=+o[1]||u,h=document.createElement("div");h.textContent=Array(m-u+2).join(" \n"),h.setAttribute("aria-hidden","true"),h.className=(i||"")+" line-highlight",t(e,"line-numbers")||(h.setAttribute("data-start",u),m>u&&h.setAttribute("data-end",m)),h.style.top=(u-l-1)*c+"px",t(e,"line-numbers")?e.appendChild(h):(e.querySelector("code")||e).appendChild(h)}}function i(){var t=location.hash.slice(1);e(".temporary.line-highlight").forEach(function(e){e.parentNode.removeChild(e)});var i=(t.match(/\.([\d,-]+)$/)||[,""])[1];if(i&&!document.getElementById(t)){var r=t.slice(0,t.lastIndexOf(".")),o=document.getElementById(r);o&&(o.hasAttribute("data-line")||o.setAttribute("data-line",""),n(o,i,"temporary "),document.querySelector(".temporary.line-highlight").scrollIntoView())}}if("undefined"!=typeof self&&self.Prism&&self.document&&document.querySelector){var r=function(){var e;return function(){if("undefined"==typeof e){var t=document.createElement("div");t.style.fontSize="13px",t.style.lineHeight="1.5",t.style.padding=0,t.style.border=0,t.innerHTML=" 
 ",document.body.appendChild(t),e=38===t.offsetHeight,document.body.removeChild(t)}return e}}(),o=0;Prism.hooks.add("complete",function(t){var r=t.element.parentNode,a=r&&r.getAttribute("data-line");r&&a&&/pre/i.test(r.nodeName)&&(clearTimeout(o),e(".line-highlight",r).forEach(function(e){e.parentNode.removeChild(e)}),n(r,a),o=setTimeout(i,1))}),window.addEventListener&&window.addEventListener("hashchange",i)}}(); 9 | !function(){"undefined"!=typeof self&&self.Prism&&self.document&&Prism.hooks.add("complete",function(e){if(e.code){var t=e.element.parentNode,s=/\s*\bline-numbers\b\s*/;if(t&&/pre/i.test(t.nodeName)&&(s.test(t.className)||s.test(e.element.className))&&!e.element.querySelector(".line-numbers-rows")){s.test(e.element.className)&&(e.element.className=e.element.className.replace(s,"")),s.test(t.className)||(t.className+=" line-numbers");var n,a=e.code.match(/\n(?!$)/g),l=a?a.length+1:1,r=new Array(l+1);r=r.join(""),n=document.createElement("span"),n.setAttribute("aria-hidden","true"),n.className="line-numbers-rows",n.innerHTML=r,t.hasAttribute("data-start")&&(t.style.counterReset="linenumber "+(parseInt(t.getAttribute("data-start"),10)-1)),e.element.appendChild(n)}}})}(); 10 | -------------------------------------------------------------------------------- /nwb.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | type: 'react-component', 3 | npm: { 4 | esModules: true, 5 | umd: { 6 | global: 'ReactState', 7 | externals: { 8 | react: 'React' 9 | } 10 | } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-state", 3 | "version": "2.2.3", 4 | "description": "Yet another react state manager", 5 | "main": "lib/index.js", 6 | "module": "es/index.js", 7 | "files": [ 8 | "css", 9 | "es", 10 | "lib", 11 | "umd" 12 | ], 13 | "scripts": { 14 | "build": "nwb build-react-component", 15 | "clean": "nwb clean-module && nwb clean-demo", 16 | "start": "nwb serve-react-demo", 17 | "test": "nwb test-react", 18 | "test:coverage": "nwb test-react --coverage", 19 | "test:watch": "nwb test-react --server", 20 | "prepublishOnly": "yarn build" 21 | }, 22 | "dependencies": { 23 | "hoist-non-react-statics": "^2.5.0" 24 | }, 25 | "peerDependencies": { 26 | "prop-types": "16.x", 27 | "react": "16.x" 28 | }, 29 | "devDependencies": { 30 | "eslint": "^4.19.1", 31 | "eslint-config-react-tools": "^1.2.5", 32 | "nwb": "0.21.x", 33 | "prop-types": "^15.6.1", 34 | "react": "^16.3.0", 35 | "react-dom": "^16.3.0" 36 | }, 37 | "author": "", 38 | "homepage": "", 39 | "license": "MIT", 40 | "repository": "", 41 | "keywords": [ 42 | "react-component" 43 | ] 44 | } 45 | -------------------------------------------------------------------------------- /src/Connect.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import PropTypes from 'prop-types' 3 | import hoistNonReactStatics from 'hoist-non-react-statics' 4 | 5 | // 6 | 7 | const alwaysUpdate = d => d 8 | const neverUpdate = () => ({}) 9 | 10 | export default function Connect (subscribe, config = { pure: true }) { 11 | // If subscribe is true, always update, 12 | // If Subscribe is truthy, expect a function 13 | // Otherwise, never update the component, only provide dispatch 14 | subscribe = subscribe === true ? alwaysUpdate : subscribe || neverUpdate 15 | 16 | const { pure } = config 17 | 18 | return ComponentToWrap => { 19 | class Connected extends Component { 20 | // let’s define what’s needed from the `context` 21 | static displayName = `Connect(${ComponentToWrap.displayName || ComponentToWrap.name})` 22 | static contextTypes = { 23 | reactState: PropTypes.object.isRequired, 24 | } 25 | constructor () { 26 | super() 27 | // Bind non-react methods 28 | this.onNotify = this.onNotify.bind(this) 29 | 30 | // Find out if subscribe returns a function 31 | let subscribePreview 32 | try { 33 | subscribePreview = subscribe() 34 | } catch (e) { 35 | // do nothing 36 | } 37 | 38 | if (typeof subscribePreview === 'function') { 39 | // If it does, make a new instance of it for this component 40 | this.subscribe = subscribe() 41 | } else { 42 | // Otherwise just use it as is 43 | this.subscribe = subscribe 44 | } 45 | } 46 | componentWillMount () { 47 | // Resolve props on mount 48 | this.resolveProps(this.props) 49 | } 50 | componentDidMount () { 51 | // Subscribe to the store for updates 52 | this.unsubscribe = this.context.reactState.subscribe(this.onNotify.bind(this), config) 53 | } 54 | componentWillReceiveProps (nextProps) { 55 | if (!pure && this.resolveProps(nextProps)) { 56 | this.forceUpdate() 57 | } 58 | } 59 | shouldComponentUpdate () { 60 | return !pure 61 | } 62 | componentWillUnmount () { 63 | this.unsubscribe() 64 | } 65 | onNotify () { 66 | if (this.resolveProps(this.props)) { 67 | this.forceUpdate() 68 | } 69 | } 70 | resolveProps (props) { 71 | const { children, ...rest } = props 72 | const { reactState } = this.context 73 | 74 | const mappedProps = this.subscribe(reactState.getStore(), rest) 75 | 76 | const newProps = { 77 | ...mappedProps, 78 | ...rest, 79 | } 80 | 81 | let needsUpdate = !this.resolvedProps 82 | 83 | if (this.resolvedProps) { 84 | Object.keys(newProps).forEach(prop => { 85 | if (!needsUpdate && this.resolvedProps[prop] !== newProps[prop]) { 86 | needsUpdate = true 87 | } 88 | }) 89 | } 90 | 91 | this.resolvedProps = newProps 92 | return needsUpdate 93 | } 94 | render () { 95 | const props = { 96 | ...this.resolvedProps, 97 | ...this.props, 98 | } 99 | return 100 | } 101 | } 102 | 103 | hoistNonReactStatics(Connected, ComponentToWrap) 104 | 105 | return Connected 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /src/Provider.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import PropTypes from 'prop-types' 3 | import hoistNonReactStatics from 'hoist-non-react-statics' 4 | 5 | // 6 | 7 | const defaultConfig = { 8 | initial: {}, 9 | } 10 | 11 | export default function (ComponentToWrap, config = defaultConfig) { 12 | class Provider extends Component { 13 | // Define our context key 14 | static childContextTypes = { 15 | reactState: PropTypes.object.isRequired, 16 | } 17 | constructor (props) { 18 | super() 19 | const { 20 | children, // eslint-disable-line 21 | ...rest 22 | } = props 23 | // Initialize the store with initial state and props 24 | this.store = { 25 | ...config.initial, 26 | ...rest, 27 | } 28 | this.subscriptions = [] 29 | this.subscribe = this.subscribe.bind(this) 30 | this.dispatch = this.dispatch.bind(this) 31 | } 32 | componentWillReceiveProps (newProps) { 33 | // If the component receives new props, merge them into 34 | // the store and notify subscribers 35 | this.dispatch(state => ({ 36 | ...state, 37 | ...newProps, 38 | })) 39 | this.forceUpdate() 40 | } 41 | subscribe (connect, meta = {}) { 42 | const subscription = { 43 | connect, 44 | meta, 45 | } 46 | // Add the subscription 47 | this.subscriptions.push(subscription) 48 | // return an unsubscribe function 49 | return () => { 50 | this.subscriptions = this.subscriptions.filter(d => d !== subscription) 51 | } 52 | } 53 | dispatch (fn, meta = {}) { 54 | // When we recieve a dispatch command, build a new version 55 | // of the store by calling the dispatch function 56 | 57 | // TODO: beforeDispatch 58 | const oldStore = this.store 59 | const newStore = fn(oldStore) 60 | // TODO: middleware 61 | this.store = newStore 62 | this.subscriptions.forEach(subscription => { 63 | let shouldNotify = true 64 | if (subscription.meta.filter) { 65 | shouldNotify = subscription.meta.filter(oldStore, newStore, meta) 66 | } 67 | if (shouldNotify) { 68 | subscription.connect() 69 | } 70 | }) 71 | this.forceUpdate() 72 | return newStore 73 | } 74 | getChildContext () { 75 | return { 76 | reactState: { 77 | getStore: () => this.store, 78 | subscribe: this.subscribe, 79 | dispatch: this.dispatch, 80 | }, 81 | } 82 | } 83 | render () { 84 | return {this.props.children} 85 | } 86 | } 87 | 88 | hoistNonReactStatics(Provider, ComponentToWrap) 89 | 90 | return Provider 91 | } 92 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import Provider from './Provider' 2 | import Connect from './Connect' 3 | 4 | export { Provider, Connect } 5 | -------------------------------------------------------------------------------- /tests/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "mocha": true 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /tests/index-test.js: -------------------------------------------------------------------------------- 1 | import expect from 'expect' 2 | import React from 'react' 3 | import {render, unmountComponentAtNode} from 'react-dom' 4 | 5 | import Component from 'src/' 6 | 7 | describe('Component', () => { 8 | let node 9 | 10 | beforeEach(() => { 11 | node = document.createElement('div') 12 | }) 13 | 14 | afterEach(() => { 15 | unmountComponentAtNode(node) 16 | }) 17 | 18 | it('displays a welcome message', () => { 19 | render(, node, () => { 20 | expect(node.innerHTML).toContain('Welcome to React components') 21 | }) 22 | }) 23 | }) 24 | --------------------------------------------------------------------------------