├── .babelrc ├── .flowconfig ├── .gitignore ├── .travis.yml ├── LICENSE ├── README.md ├── __tests__ ├── Context-test.js ├── ReComponent-test.js ├── RePureComponent-test.js ├── UpdateTypes-test.js ├── __snapshots__ │ ├── ReComponent-test.js.snap │ └── UpdateTypes-test.js.snap └── helpers.js ├── dist ├── react-recomponent.js.flow ├── react-recomponent.m.js.flow └── react-recomponent.umd.js.flow ├── package.json ├── src ├── index.js ├── re.js └── update-types.js ├── type-definitions ├── ReComponent.d.ts ├── ReComponent.js.flow ├── __tests__ │ └── ReComponent.js └── ts-tests │ ├── exports.ts │ ├── index.d.ts │ ├── tsconfig.json │ ├── tslint.json │ └── updateTypes.ts └── yarn.lock /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["env", "react"] 3 | } 4 | -------------------------------------------------------------------------------- /.flowconfig: -------------------------------------------------------------------------------- 1 | [options] 2 | suppress_comment=\\(.\\|\n\\)*\\$ExpectError 3 | suppress_comment=\\(.\\|\n\\)*\\$FlowFixMe 4 | include_warnings=true 5 | 6 | [ignore] 7 | node_modules/.* 8 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | node_modules/ 3 | coverage/ 4 | dist/*.js 5 | dist/*.ts 6 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "node" 4 | install: 5 | - "yarn global add codecov" 6 | - "yarn install --freeze-lockfile" 7 | script: 8 | - "yarn test:js --coverage" 9 | - "yarn prettier --list-different" 10 | - "yarn test:types:flow" 11 | - "yarn test:types:ts" 12 | - "codecov" 13 | cache: yarn -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Philipp Spieß 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 |

2 | 3 | ReComponent 4 | 5 |

6 | 7 |

8 | Reason-style reducer components for React using ES6 classes. 9 |

10 | 11 |

12 | npm 13 | 14 | GitHub license 15 | 16 | 17 | Travis 18 | 19 | 20 | Codecov 21 | 22 |

23 | 24 | --- 25 | 26 | A number of solutions to manage state in React applications are based on the concept of a "reducer" to decouple actions from effects. The reducer is a function that transforms the state in response to actions. Examples for such solutions are the [Redux][] library and architectures like [Flux][]. 27 | 28 | Most recently this pattern was implemented in [ReasonReact][] as the built-in solution to manage local component state. Similarly to Redux, ReasonReact components implement a reducer and actions to trigger state changes but do so while staying completely inside regular React state. These components are referred as reducer components. 29 | 30 | _ReComponent_ borrows these ideas from ReasonReact and brings reducer components to the React ecosystem. 31 | 32 | A reducer component is used like a regular, stateful, React component with the difference that `setState` is not allowed. Instead, state is updated through a `reducer` which is triggered by sending actions to it. 33 | 34 | - [Installation](#installation) 35 | - [Getting Started](#getting-started) 36 | - [FAQ](#faq) 37 | - [Advanced Usage](#advanced-usage) 38 | - [Effects](#effects) 39 | - [Handling Events](#handling-events) 40 | - [Manage State Across the Tree](#manage-state-across-the-tree) 41 | - [Flow](#flow) 42 | - [TypeScript](#typescript) 43 | - [API Reference](#api-reference) 44 | 45 | ## Installation 46 | 47 | ``` 48 | npm install react-recomponent --save 49 | ``` 50 | 51 | ## Getting Started 52 | 53 | To create a reducer component extend `ReComponent` from `react-recomponent` instead of `React.Component`. 54 | 55 | With `ReComponent` state can only be modified by sending actions to the `reducer()` function. To help with that, you can use `createSender()`. Take a look at a simple counter example: 56 | 57 | ```js 58 | import React from "react"; 59 | import { ReComponent, Update } from "react-recomponent"; 60 | 61 | class Counter extends ReComponent { 62 | constructor() { 63 | super(); 64 | this.handleClick = this.createSender("CLICK"); 65 | this.state = { count: 0 }; 66 | } 67 | 68 | static reducer(action, state) { 69 | switch (action.type) { 70 | case "CLICK": 71 | return Update({ count: state.count + 1 }); 72 | } 73 | } 74 | 75 | render() { 76 | return ( 77 | 80 | ); 81 | } 82 | } 83 | ``` 84 | 85 | [![Edit ReComponent - Getting Started](https://codesandbox.io/static/img/play-codesandbox.svg)](https://codesandbox.io/s/zq0210299x) 86 | 87 | The `Counter` component starts with an initial state of `{ count: 0 }`. Note that this state is in fact a regular React component state. To update it, we use a click action which we identify by its type `"CLICK"` (this is similar to the way actions are identified in Redux). 88 | 89 | The `reducer` will receive this action and act accordingly. In our case, it will return an `Update()` effect with the modified state. 90 | 91 | ReComponent comes with four different types of [effects](https://github.com/philipp-spiess/react-recomponent#effects): 92 | 93 | - `NoUpdate()` signalize that nothing should happen. 94 | - `Update(state)` update the state. 95 | - `SideEffects(fn)` run an arbitrary function which has [side effect]s. Side effects may never be run directly inside the reducer. **A reducer should always be pure**: for the same action applied onto the same state, it should return the same effects. **This is to avoid bugs when React will work asynchronously**. 96 | - `UpdateWithSideEffects(state, fn)` both update the state and then trigger the side effect. 97 | 98 | By intelligently using any of the four types above, it is possible to transition between states in one place and without the need to use `setState()` manually. This drastically simplifies our mental model since changes must always go through the reducer first. 99 | 100 | **NOTE!** 101 | You should NEVER call `this.send` or any sender in **`componentWillUnmount`**. 102 | If you need to execute a side-effect in **`componentWillUnmount`** (e.g. clear a timer) call that side-effect directly. 103 | 104 | ## FAQ 105 | 106 | ### Advantages Over `setState` 107 | 108 | The advantages are similar to those of [Redux](https://github.com/reduxjs/redux) or really any state management tool: 109 | 110 | 1. **Decoupling** your state transformers from the rest of the code. This can be a little cumbersome when working with React alone since you will scatter a variety of setState inside your components which becomes harder to follow when the component grows. The sender/reducer system simplifies this since you will no longer focus on state changes within the various methods of your component but you’ll think of actions that you want to send which contains all the information as a standalone object. With that, adding additional behavior (like logging) becomes very easy since all you have to do is hook this logic inside the reducer. 111 | 112 | 2. Improved **maintainability** by forcing a structure. With [Redux](https://github.com/reduxjs/redux) or [ReComponent](https://github.com/philipp-spiess/react-recomponent), you have a good overview of all actions that your application can send. This is an amazing property and allows others to easily understand what a component is is (actually) doing. While you can already learn so much by looking at the shape of the state object, you’ll lean even more just by _looking at the action types alone_. And since it’s not allowed to use setState at all, you can also be certain that all the code inside the reducer is the only place that transforms your state. 113 | 114 | 3. Get rid of side effects with **Pure State Transformation**. By keeping your state changes side effect free, you’re forced into writing code that is easier to test (given an action and a state, it must _always_ return the same new state). Plus you can build extended event sourcing features on top of that since you can easily store all actions that where send to your reducers and replay them later (to go back in time and see exactly how an invalid state occurred). 115 | 116 | ### Why is the reducer `static`? 117 | 118 | To fully leverage all of the advantages outlined above, the reducer function must not have any side effects. Making the reducer `static` will enforce this behavior since you won’t have access to `this` inside the function. We identified three situations that could need `this` inside the reducer: 119 | 120 | 1. You’re about to read class properties. In this case, make sure those properties are properly encapsulated in the state object. 121 | 2. You’re about to write class properties. This is a side effect and should be handled using the `SideEffects(fn)` effect. 122 | 3. You’re accessing a function that is pure by itself. In this case, the function does not need to be a class property but can be a regular module function instead. 123 | 124 | ## Advanced Usage 125 | 126 | Now that we‘ve learned how to use reducer components with React, it‘s time to look into more advanced use cases to effectively handle state transitions across bigger portions of your app. 127 | 128 | ### Effects 129 | 130 | We‘ve already said that ReComponent comes with four different types of [effects](https://github.com/philipp-spiess/react-recomponent#effects). This is necessary to effectively handle side effects by keeping your reducer pure – given the same state and action, it will always return the same effects. 131 | 132 | The following example will demonstrate the four different types of effects and show you how to use them: 133 | 134 | ```js 135 | import React from "react"; 136 | import { 137 | ReComponent, 138 | NoUpdate, 139 | Update, 140 | SideEffects, 141 | UpdateWithSideEffects 142 | } from "react-recomponent"; 143 | 144 | class Counter extends ReComponent { 145 | constructor() { 146 | super(); 147 | this.handleNoUpdate = this.createSender("NO_UPDATE"); 148 | this.handleUpdate = this.createSender("UPDATE"); 149 | this.handleSideEffects = this.createSender("SIDE_EFFECTS"); 150 | this.handleUpdateWithSideEffects = this.createSender( 151 | "UPDATE_WITH_SIDE_EFFECTS" 152 | ); 153 | this.state = { count: 0 }; 154 | } 155 | 156 | static reducer(action, state) { 157 | switch (action.type) { 158 | case "NO_UPDATE": 159 | return NoUpdate(); 160 | case "UPDATE": 161 | return Update({ count: state.count + 1 }); 162 | case "SIDE_EFFECTS": 163 | return SideEffects(() => console.log("This is a side effect")); 164 | case "UPDATE_WITH_SIDE_EFFECTS": 165 | return UpdateWithSideEffects({ count: state.count + 1 }, () => 166 | console.log("This is another side effect") 167 | ); 168 | } 169 | } 170 | 171 | render() { 172 | return ( 173 | 174 | 175 | 176 | 177 | 180 | 181 |
The current counter is: {this.state.count}
182 |
183 | ); 184 | } 185 | } 186 | ``` 187 | 188 | [![Edit ReComponent - Effects 1](https://codesandbox.io/static/img/play-codesandbox.svg)](https://codesandbox.io/s/5x4o7m8vxl) 189 | 190 | All side effect callbacks get a reference to the react component passed as the first argument. This is helpful when a side effect needs to send other actions to the reducer. The next example shows how you can leverage this to handle a more complex component that fetches data from a third party and has to handle multiple states: 191 | 192 | ```js 193 | import React from "react"; 194 | import { 195 | ReComponent, 196 | NoUpdate, 197 | Update, 198 | UpdateWithSideEffects 199 | } from "react-recomponent"; 200 | 201 | import { fetchData } from "./api"; 202 | 203 | class Fetcher extends ReComponent { 204 | constructor() { 205 | super(); 206 | this.handleRequestStart = this.createSender("REQUEST_START"); 207 | this.handleRequestSuccess = this.createSender("REQUEST_SUCCESS"); 208 | this.handleRequestFail = this.createSender("REQUEST_FAIL"); 209 | this.state = { isFetching: false, result: null }; 210 | } 211 | 212 | static reducer(action, state) { 213 | switch (action.type) { 214 | case "REQUEST_START": 215 | if (state.isFetching) { 216 | return NoUpdate(); 217 | } else { 218 | return UpdateWithSideEffects({ isFetching: true }, instance => { 219 | fetchData().then( 220 | instance.handleRequestSuccess, 221 | instance.handleRequestFail 222 | ); 223 | }); 224 | } 225 | case "REQUEST_SUCCESS": 226 | return Update({ result: action.payload, isFetching: false }); 227 | case "REQUEST_FAIL": 228 | return Update({ 229 | result: "The data could not be fetched. Maybe try again?", 230 | isFetching: false 231 | }); 232 | } 233 | } 234 | 235 | render() { 236 | return ( 237 | 238 | 239 |
240 | {this.state.isFetching &&

Loading...

} 241 |

242 | {this.state.result ? this.state.result : 'Click "Fetch" to start'} 243 |

244 |
245 |
246 | ); 247 | } 248 | } 249 | ``` 250 | 251 | [![Edit ReComponent - Effects 2](https://codesandbox.io/static/img/play-codesandbox.svg)](https://codesandbox.io/s/n4pj54y4l) 252 | 253 | ### Handling Events 254 | 255 | React uses a method called pooling to improve performance when emitting events (check out the guides on [`SyntheticEvent`](https://reactjs.org/docs/events.html) to learn more). Basically React recycles events once the callback is handled making any reference to them unavailable. 256 | 257 | Since the reducer function always runs within the `setState()` callback provided by React, synthetic events will already be recycled by the time the reducer is invoked. To be able to access event properties, we recommend passing the required values explicitly. The following example will show the coordinates of the last mouse click. To have control over which properties are sent to the reducer, we‘re using `send` directly in this case: 258 | 259 | ```js 260 | import React from "react"; 261 | import { ReComponent, Update } from "react-recomponent"; 262 | 263 | class Counter extends ReComponent { 264 | constructor() { 265 | super(); 266 | this.handleClick = this.handleClick.bind(this); 267 | this.state = { x: 0, y: 0 }; 268 | } 269 | 270 | handleClick(event) { 271 | this.send({ 272 | type: "CLICK", 273 | payload: { 274 | x: event.clientX, 275 | y: event.clientY 276 | } 277 | }); 278 | } 279 | 280 | static reducer(action, state) { 281 | switch (action.type) { 282 | case "CLICK": 283 | return Update({ 284 | x: action.payload.x, 285 | y: action.payload.y 286 | }); 287 | } 288 | } 289 | 290 | render() { 291 | const { x, y } = this.state; 292 | 293 | const style = { 294 | width: "100vw", 295 | height: "100vh" 296 | }; 297 | 298 | return ( 299 |
300 | Last click at: {x}, {y} 301 |
302 | ); 303 | } 304 | } 305 | ``` 306 | 307 | [![Edit ReComponent - Handling Events](https://codesandbox.io/static/img/play-codesandbox.svg)](https://codesandbox.io/s/8yxqzw23n2) 308 | 309 | ### Manage State Across the Tree 310 | 311 | Often times we want to pass state properties to descendants that are very deep in the application tree. In order to do so, the components in between need to pass those properties to their respective children until we reach the desired component. This pattern is usually called [prop drilling](https://blog.kentcdodds.com/prop-drilling-bb62e02cb691) and it is usually what you want to do. 312 | 313 | Sometimes, however, the layers in-between are expensive to re-render causing your application to become janky. Fortunately, React 16.3.0 introduced a new API called [`createContext()`](https://reactjs.org/docs/context.html#reactcreatecontext) that we can use to solve this issue by using context to pass those properties directly to the target component and skipping the update of all intermediate layers: 314 | 315 | ```js 316 | import React from "react"; 317 | import { ReComponent, Update } from "react-recomponent"; 318 | 319 | const { Provider, Consumer } = React.createContext(); 320 | 321 | class Counter extends React.Component { 322 | render() { 323 | return ( 324 | 325 | {({ state, handleClick }) => ( 326 | 329 | )} 330 | 331 | ); 332 | } 333 | } 334 | 335 | class DeepTree extends React.Component { 336 | render() { 337 | return ; 338 | } 339 | } 340 | 341 | class Container extends ReComponent { 342 | constructor() { 343 | super(); 344 | this.handleClick = this.createSender("CLICK"); 345 | this.state = { count: 0 }; 346 | } 347 | 348 | static reducer(action, state) { 349 | switch (action.type) { 350 | case "CLICK": 351 | return Update({ count: state.count + 1 }); 352 | } 353 | } 354 | 355 | render() { 356 | return ( 357 | 358 | 359 | 360 | ); 361 | } 362 | } 363 | ``` 364 | 365 | [![Edit ReComponent - Manage State Across the Tree](https://codesandbox.io/static/img/play-codesandbox.svg)](https://codesandbox.io/s/0q9l907m7p) 366 | 367 | If you‘re having troubles understanding this example, I recommend the fantastic documentation written by the React team about [Context](https://reactjs.org/docs/context.html#reactcreatecontext). 368 | 369 | ### Flow 370 | 371 | _[Flow][] is a static type checker for JavaScript. This section is only relevant for you if you‘re using Flow in your application._ 372 | 373 | ReComponent comes with first class Flow support built in. 374 | When extending `ReComponent`, in addition to the `Props` and `State` types required by regular `React.Component` 375 | we need to specify the third generic parameter which should be a union of all actions used by the component. 376 | This ensures type-safety everywhere in the code of the component where the actions are used and 377 | even allows [exhaustiveness testing] to verify that every action is indeed handled. 378 | 379 | ```js 380 | import * as React from "react"; 381 | import { ReComponent, Update } from "react-recomponent"; 382 | 383 | type Props = {}; 384 | type State = { count: number, value: string }; 385 | type Action = { type: "CLICK" } | { type: "CHANGE", payload: string }; 386 | 387 | class TypedActions extends ReComponent { 388 | // NOTE: We use `this.send()` API because it ensures type-safety for 389 | // an action's `payload`. 390 | handleClick = () => this.send({ type: "CLICK" }); 391 | handleChange = (event: Event) => 392 | this.send({ type: "CHANGE", payload: event.target.value }); 393 | 394 | state = { count: 0, value: "" }; 395 | 396 | static reducer(action, state) { 397 | switch (action.type) { 398 | case "CLICK": 399 | return Update({ count: state.count + 1 }); 400 | case "CHANGE": 401 | return Update({ value: action.payload }); 402 | } 403 | } 404 | } 405 | 406 | render() { 407 | return ( 408 |
409 | 412 | 413 |
414 | ); 415 | } 416 | } 417 | ``` 418 | 419 | Check out the [type definition tests](https://github.com/philipp-spiess/react-recomponent/blob/master/type-definitions/__tests__/ReComponent.js) for an example on exhaustive checking. 420 | 421 | **Known Limitations With Flow:** 422 | 423 | - `this.send` API for sending actions is preferred over `this.createSender`. This is because `this.createSender` 424 | effectively types the payload as `any` (limitation we can't overcome for now), whereas `this.send` provides full type-safety 425 | for actions 426 | - While it is possible to exhaustively type check the reducer, Flow will still require every branch to return an effect. 427 | This is why the above examples returns `NoUpdate()` even though the branch can never be reached. 428 | 429 | ### TypeScript 430 | 431 | In addition to [Flow](#flow), _ReComponent_ also comes with [TypeScript](https://www.typescriptlang.org/) definitions built-in. 432 | 433 | You can learn more about our TypeScript support by looking at the [declaration](type-definitions/ReComponent.d.ts) and the accompanying [tests](type-definitions/ts-tests). 434 | 435 | ## API Reference 436 | 437 | ### Classes 438 | 439 | - `ReComponent` 440 | 441 | - `static reducer(action, state): effect` 442 | 443 | Translates an action into an effect. This is the main place to update your component‘s state. 444 | 445 | **Note:** Reducers should never trigger side effects directly. Instead, return them as effects. 446 | 447 | - `send(action): void` 448 | 449 | Sends an action to the reducer. The action _must_ have a `type` property so the reducer can identify it. 450 | 451 | - `createSender(actionType): fn` 452 | 453 | Shorthand function to create a function that will send an action of the `actionType` type to the reducer. 454 | 455 | If the sender function is called with an argument (for example a React event), this will be available at the `payload` prop. This follows the [flux-standard-actions][] naming convention. 456 | 457 | - `RePureComponent` 458 | - Same `ReComponent` but based on [`React.PureComponent`](https://reactjs.org/docs/react-api.html#reactpurecomponent) instead. 459 | 460 | ### Effects 461 | 462 | - `NoUpdate()` 463 | 464 | Returning this effect will not cause the state to be updated. 465 | 466 | - `Update(state)` 467 | 468 | Returning this effect will update the state. Internally, this will use `setState()` with an updater function. 469 | 470 | - `SideEffects(this => mixed)` 471 | 472 | Enqueues side effects to be run but will not update the component‘s state. The side effect will be called with a reference to the react component (`this`) as the first argument. 473 | 474 | - `UpdateWithSideEffects(state, this => mixed)` 475 | 476 | Updates the component‘s state and _then_ calls the side effect function.The side effect will be called with a reference to the react component (`this`) as the first argument. 477 | 478 | ## Contributing 479 | 480 | Every help on this project is greatly appreciated. To get you started, here's a quick guide on how to make good and clean pull-requests: 481 | 482 | 1. Create a fork of this [repository](https://github.com/philipp-spiess/react-recomponent), so you can work on your own environment. 483 | 2. Install development dependencies locally: 484 | 485 | ```bash 486 | git clone git@github.com:/react-recomponent.git 487 | cd react-recomponent 488 | yarn install 489 | ``` 490 | 491 | 3. Make changes using your favorite editor. 492 | 4. Make sure that all tests are passing and that the code is formatted correctly: 493 | 494 | ```bash 495 | yarn format 496 | yarn test 497 | yarn test:types:flow 498 | ``` 499 | 500 | 5. Commit your changes ([here](https://chris.beams.io/posts/git-commit/) is a wonderful guide on how to make amazing git commits). 501 | 6. After a few seconds, a button to create a pull request should be visible inside the [Pull requests](https://github.com/philipp-spiess/react-recomponent/pulls) section. 502 | 503 | ## License 504 | 505 | [MIT](https://github.com/philipp-spiess/react-recomponent/blob/master/README.md) 506 | 507 | [redux]: https://github.com/reduxjs/redux 508 | [reasonreact]: https://reasonml.github.io/reason-react/docs/en/state-actions-reducer.html 509 | [flux]: https://facebook.github.io/flux/ 510 | [side effect]: https://en.wikipedia.org/wiki/Side_effect_(computer_science) 511 | [flow]: https://flow.org/en/ 512 | [exhaustiveness testing]: https://blog.jez.io/flow-exhaustiveness/ 513 | [flux-standard-actions]: https://github.com/redux-utilities/flux-standard-action 514 | -------------------------------------------------------------------------------- /__tests__/Context-test.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOM from "react-dom"; 3 | 4 | import { ReComponent, Update } from "../src"; 5 | 6 | import { click } from "./helpers"; 7 | 8 | describe("ReComponent", () => { 9 | let container; 10 | beforeEach(() => { 11 | container = document.createElement("div"); 12 | document.body.appendChild(container); 13 | }); 14 | 15 | const { Provider, Consumer } = React.createContext(); 16 | 17 | class Counter extends React.Component { 18 | render() { 19 | return ( 20 | 21 | {({ state, handleClick }) => ( 22 | 25 | )} 26 | 27 | ); 28 | } 29 | } 30 | 31 | class DeepTree extends React.Component { 32 | render() { 33 | return ; 34 | } 35 | } 36 | 37 | class Container extends ReComponent { 38 | constructor() { 39 | super(); 40 | this.handleClick = () => this.send({ type: "CLICK" }); 41 | this.state = { count: 0 }; 42 | } 43 | 44 | static reducer(action, state) { 45 | switch (action.type) { 46 | case "CLICK": 47 | return Update({ count: state.count + 1 }); 48 | } 49 | } 50 | 51 | render() { 52 | return ( 53 | 54 | 55 | 56 | ); 57 | } 58 | } 59 | 60 | it("renders the initial state", () => { 61 | const instance = ReactDOM.render(, container); 62 | expect(container.textContent).toEqual("You’ve clicked this 0 times(s)"); 63 | }); 64 | 65 | it("increases the counter when clicked", () => { 66 | const instance = ReactDOM.render(, container); 67 | click(container.firstChild); 68 | expect(container.textContent).toEqual("You’ve clicked this 1 times(s)"); 69 | }); 70 | }); 71 | -------------------------------------------------------------------------------- /__tests__/ReComponent-test.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOM from "react-dom"; 3 | 4 | import { ReComponent, Update } from "../src"; 5 | 6 | import { click, withConsoleMock } from "./helpers"; 7 | 8 | describe("ReComponent", () => { 9 | let container; 10 | beforeEach(() => { 11 | container = document.createElement("div"); 12 | document.body.appendChild(container); 13 | }); 14 | 15 | class Example extends ReComponent { 16 | constructor() { 17 | super(); 18 | this.handleClick = this.createSender("CLICK"); 19 | this.state = { count: 0 }; 20 | } 21 | 22 | static reducer(action, state) { 23 | switch (action.type) { 24 | case "CLICK": 25 | return Update({ count: state.count + 1 }); 26 | } 27 | } 28 | 29 | render() { 30 | return ( 31 | 34 | ); 35 | } 36 | } 37 | 38 | it("renders the initial state", () => { 39 | const instance = ReactDOM.render(, container); 40 | expect(container.textContent).toEqual("You’ve clicked this 0 times(s)"); 41 | }); 42 | 43 | it("increases the counter when clicked", () => { 44 | const instance = ReactDOM.render(, container); 45 | click(container.firstChild); 46 | expect(container.textContent).toEqual("You’ve clicked this 1 times(s)"); 47 | }); 48 | 49 | it("errors when no `reducer` method is defined", () => { 50 | class Example extends ReComponent { 51 | render() { 52 | return
; 53 | } 54 | } 55 | 56 | withConsoleMock(() => { 57 | expect(() => { 58 | ReactDOM.render(, container); 59 | }).toThrowErrorMatchingSnapshot(); 60 | }); 61 | }); 62 | 63 | it("disables `setState`", () => { 64 | let setState; 65 | class Example extends ReComponent { 66 | constructor() { 67 | super(); 68 | setState = () => this.setState({ some: "state" }); 69 | } 70 | static reducer() {} 71 | render() { 72 | return null; 73 | } 74 | } 75 | 76 | ReactDOM.render(, container); 77 | withConsoleMock(() => { 78 | expect(() => setState()).toThrowErrorMatchingSnapshot(); 79 | }); 80 | }); 81 | 82 | it("does not throw errors in production", () => { 83 | let originalNodeEnv = process.env.NODE_ENV; 84 | try { 85 | process.env.NODE_ENV = "production"; 86 | 87 | let click; 88 | class Example extends ReComponent { 89 | constructor() { 90 | super(); 91 | click = () => this.send({ type: "CLICK" }); 92 | } 93 | 94 | static reducer(action, state) { 95 | return {}; 96 | } 97 | 98 | render() { 99 | return null; 100 | } 101 | } 102 | 103 | ReactDOM.render(, container); 104 | expect(() => click()).not.toThrowError(); 105 | } finally { 106 | process.env.NODE_ENV = originalNodeEnv; 107 | } 108 | }); 109 | }); 110 | -------------------------------------------------------------------------------- /__tests__/RePureComponent-test.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOM from "react-dom"; 3 | 4 | import { RePureComponent, Update } from "../src"; 5 | 6 | import { click } from "./helpers"; 7 | 8 | describe("RePureComponent", () => { 9 | let container; 10 | beforeEach(() => { 11 | container = document.createElement("div"); 12 | document.body.appendChild(container); 13 | }); 14 | 15 | class Example extends RePureComponent { 16 | constructor() { 17 | super(); 18 | this.handleClick = this.createSender("CLICK"); 19 | this.state = { count: 0 }; 20 | } 21 | 22 | static reducer(action, state) { 23 | switch (action.type) { 24 | case "CLICK": 25 | return Update({ count: state.count + 1 }); 26 | } 27 | } 28 | 29 | render() { 30 | return ( 31 | 34 | ); 35 | } 36 | } 37 | 38 | it("renders the initial state", () => { 39 | const instance = ReactDOM.render(, container); 40 | expect(container.textContent).toEqual("You’ve clicked this 0 times(s)"); 41 | }); 42 | 43 | it("increases the counter when clicked", () => { 44 | const instance = ReactDOM.render(, container); 45 | click(container.firstChild); 46 | expect(container.textContent).toEqual("You’ve clicked this 1 times(s)"); 47 | }); 48 | }); 49 | -------------------------------------------------------------------------------- /__tests__/UpdateTypes-test.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOM from "react-dom"; 3 | 4 | import { 5 | ReComponent, 6 | NoUpdate, 7 | Update, 8 | SideEffects, 9 | UpdateWithSideEffects 10 | } from "../src"; 11 | 12 | import { withConsoleMock } from "./helpers"; 13 | 14 | describe("UpdateTypes", () => { 15 | let container, 16 | sideEffectSpy, 17 | noUpdate, 18 | update, 19 | sideEffects, 20 | updateWithSideEffects, 21 | invalid, 22 | unhandled, 23 | numberOfRenders; 24 | beforeEach(() => { 25 | container = document.createElement("div"); 26 | document.body.appendChild(container); 27 | numberOfRenders = 0; 28 | sideEffectSpy = jest.fn(); 29 | }); 30 | 31 | class ReducerReturns extends ReComponent { 32 | constructor() { 33 | super(); 34 | noUpdate = () => this.send({ type: "NO_UPDATE" }); 35 | update = () => this.send({ type: "UPDATE" }); 36 | sideEffects = () => this.send({ type: "SIDE_EFFECTS" }); 37 | updateWithSideEffects = () => 38 | this.send({ type: "UPDATE_WITH_SIDE_EFFECTS" }); 39 | invalid = () => this.send({ type: "INVALID" }); 40 | unhandled = () => this.send({ type: "UNHANDLED" }); 41 | this.state = { count: 0 }; 42 | } 43 | 44 | static reducer(action, state) { 45 | switch (action.type) { 46 | case "NO_UPDATE": 47 | return NoUpdate(); 48 | case "UPDATE": 49 | return Update({ count: state.count + 1 }); 50 | case "SIDE_EFFECTS": 51 | return SideEffects(sideEffectSpy); 52 | case "UPDATE_WITH_SIDE_EFFECTS": 53 | return UpdateWithSideEffects( 54 | { count: state.count + 1 }, 55 | sideEffectSpy 56 | ); 57 | case "INVALID": 58 | return {}; 59 | default: 60 | return; 61 | } 62 | } 63 | 64 | render() { 65 | // We use this to assert that certain effects do not re-render the state. 66 | numberOfRenders++; 67 | 68 | return ( 69 | 70 | You’ve clicked {this.state.count} times(s) 71 | 72 | ); 73 | } 74 | } 75 | 76 | describe("NoUpdate", () => { 77 | it("does not update the state", () => { 78 | const instance = ReactDOM.render(, container); 79 | noUpdate(); 80 | expect(container.textContent).toEqual("You’ve clicked 0 times(s)"); 81 | expect(sideEffectSpy).not.toHaveBeenCalled(); 82 | }); 83 | 84 | it("does not re-render", () => { 85 | const instance = ReactDOM.render(, container); 86 | noUpdate(); 87 | expect(numberOfRenders).toEqual(1); 88 | }); 89 | }); 90 | 91 | describe("Update", () => { 92 | it("updates the state", () => { 93 | const instance = ReactDOM.render(, container); 94 | update(); 95 | expect(container.textContent).toEqual("You’ve clicked 1 times(s)"); 96 | expect(sideEffectSpy).not.toHaveBeenCalled(); 97 | }); 98 | 99 | it("re-renders", () => { 100 | const instance = ReactDOM.render(, container); 101 | update(); 102 | expect(numberOfRenders).toEqual(2); 103 | }); 104 | }); 105 | 106 | describe("SideEffects", () => { 107 | it("does not update the state but triggers the side effect", () => { 108 | const instance = ReactDOM.render(, container); 109 | sideEffects(); 110 | expect(container.textContent).toEqual("You’ve clicked 0 times(s)"); 111 | expect(sideEffectSpy).toHaveBeenCalledWith(instance); 112 | }); 113 | 114 | it("does not re-render", () => { 115 | const instance = ReactDOM.render(, container); 116 | sideEffects(); 117 | expect(numberOfRenders).toEqual(1); 118 | }); 119 | }); 120 | 121 | describe("UpdateWithSideEffects", () => { 122 | it("updates the state and triggers the side effect", () => { 123 | const instance = ReactDOM.render(, container); 124 | updateWithSideEffects(); 125 | expect(container.textContent).toEqual("You’ve clicked 1 times(s)"); 126 | expect(sideEffectSpy).toHaveBeenCalledWith(instance); 127 | }); 128 | 129 | it("re-renders", () => { 130 | const instance = ReactDOM.render(, container); 131 | updateWithSideEffects(); 132 | expect(numberOfRenders).toEqual(2); 133 | }); 134 | }); 135 | 136 | it("throws when an invalid value was returned", () => { 137 | ReactDOM.render(, container); 138 | withConsoleMock(() => 139 | expect(() => invalid()).toThrowErrorMatchingSnapshot() 140 | ); 141 | }); 142 | 143 | it("throws when no value was returned", () => { 144 | const instance = ReactDOM.render(, container); 145 | withConsoleMock(() => 146 | expect(() => unhandled()).toThrowErrorMatchingSnapshot() 147 | ); 148 | }); 149 | }); 150 | -------------------------------------------------------------------------------- /__tests__/__snapshots__/ReComponent-test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`ReComponent disables \`setState\` 1`] = `"Example(...): Calls to \`setState\` are not allowed. Please use the \`reducer\` method to update the component state"`; 4 | 5 | exports[`ReComponent errors when no \`reducer\` method is defined 1`] = `"Example(...): No static \`reducer\` method found on the returned component instance: did you define a reducer?"`; 6 | -------------------------------------------------------------------------------- /__tests__/__snapshots__/UpdateTypes-test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`UpdateTypes throws when an invalid value was returned 1`] = `"ReducerReturns(...): Return value of \`reducer\` method is not a valid action. Please use: \`NoUpdate()\`, \`Update(state)\`, \`SideEffects(fn)\`, or \`UpdateWithSideEffects(state, fn)\` instead."`; 4 | 5 | exports[`UpdateTypes throws when no value was returned 1`] = `"ReducerReturns(...): \`reducer\` method returned \`undefined\`: did you forget to handle this action? Please return \`NoUpdate()\`, \`Update(state)\`, \`SideEffects(fn)\`, or \`UpdateWithSideEffects(state, fn)\` instead."`; 6 | -------------------------------------------------------------------------------- /__tests__/helpers.js: -------------------------------------------------------------------------------- 1 | export function click(element) { 2 | element.dispatchEvent(new Event("click", { bubbles: true })); 3 | } 4 | 5 | export function withConsoleMock(fn) { 6 | const originalError = console.error; 7 | console.error = jest.fn(); 8 | try { 9 | fn(); 10 | } finally { 11 | console.error = originalError; 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /dist/react-recomponent.js.flow: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | 3 | export * from '../type-definitions/ReComponent.js.flow'; 4 | -------------------------------------------------------------------------------- /dist/react-recomponent.m.js.flow: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | 3 | export * from '../type-definitions/ReComponent.js.flow'; 4 | -------------------------------------------------------------------------------- /dist/react-recomponent.umd.js.flow: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | 3 | export * from '../type-definitions/ReComponent.js.flow'; 4 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-recomponent", 3 | "version": "1.0.0-rc.3", 4 | "main": "dist/react-recomponent.js", 5 | "typings": "type-definitions/ReComponent.d.ts", 6 | "umd:main": "dist/react-recomponent.umd.js", 7 | "module": "dist/react-recomponent.m.js", 8 | "source": "src/index.js", 9 | "license": "MIT", 10 | "files": [ 11 | "dist", 12 | "type-definitions/ReComponent.js.flow", 13 | "type-definitions/ReComponent.d.ts", 14 | "README.md", 15 | "LICENSE" 16 | ], 17 | "devDependencies": { 18 | "babel-core": "^6.26.3", 19 | "babel-jest": "^23.6.0", 20 | "babel-preset-env": "^1.7.0", 21 | "babel-preset-react": "^6.24.1", 22 | "dtslint": "^0.3.0", 23 | "flow-bin": "^0.82.0", 24 | "jest": "^23.6.0", 25 | "microbundle": "^0.6.0", 26 | "npm-run-all": "^4.1.3", 27 | "prettier": "^1.14.3", 28 | "react": "^16.5.2", 29 | "react-dom": "^16.5.2" 30 | }, 31 | "peerDependencies": { 32 | "react": "^16.4.0" 33 | }, 34 | "scripts": { 35 | "test": "run-p test:js test:types:*", 36 | "prettier": "prettier {{__tests__,src,type-definitions}/**/*.{js,flow,ts},README.md}", 37 | "format": "npm run prettier -- --write", 38 | "test:js": "jest", 39 | "test:types:ts": "yarn build && tsc ./type-definitions/ReComponent.d.ts --lib es2015 && dtslint type-definitions/ts-tests", 40 | "test:types:flow": "flow check .", 41 | "build": "microbundle -o dist/ --name ReComponent --compress false --sourcemap false", 42 | "prepublishOnly": "yarn build" 43 | }, 44 | "jest": { 45 | "testRegex": "(/__tests__/.*\\-(test))\\.js$", 46 | "coverageDirectory": "./coverage/" 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import { Component, PureComponent } from "react"; 2 | 3 | import { Re } from "./re"; 4 | 5 | export { 6 | NoUpdate, 7 | Update, 8 | SideEffects, 9 | UpdateWithSideEffects 10 | } from "./update-types"; 11 | 12 | export const ReComponent = Re(Component); 13 | export const RePureComponent = Re(PureComponent); 14 | -------------------------------------------------------------------------------- /src/re.js: -------------------------------------------------------------------------------- 1 | import { 2 | NO_UPDATE, 3 | UPDATE, 4 | SIDE_EFFECTS, 5 | UPDATE_WITH_SIDE_EFFECTS 6 | } from "./update-types"; 7 | 8 | export function Re(Component) { 9 | return class extends Component { 10 | constructor(props) { 11 | super(props); 12 | 13 | if (process.env.NODE_ENV !== "production") { 14 | const name = this.displayName || this.constructor.name; 15 | 16 | if (typeof this.constructor.reducer !== "function") { 17 | throw new Error( 18 | name + 19 | "(...): No static `reducer` method found on the returned " + 20 | "component instance: did you define a reducer?" 21 | ); 22 | } 23 | } 24 | 25 | let setState = this.setState; 26 | if (process.env.NODE_ENV !== "production") { 27 | this.setState = () => { 28 | const name = this.displayName || this.constructor.name; 29 | throw new Error( 30 | name + 31 | "(...): Calls to `setState` are not allowed. Please use the " + 32 | "`reducer` method to update the component state" 33 | ); 34 | }; 35 | } 36 | 37 | // Sends an `action` to the reducer. The `reducer` must handle this action 38 | // and return either `NoUpdate()`, `Update(state)`, `SideEffects(fn)`, or 39 | // `UpdateWithSideEffects(state, fn)`. 40 | // 41 | // To avoid defining functions that call `ReComponent#send` in the render 42 | // method, we also expose a convenience method: `ReComponent#createSender`. 43 | // 44 | // @see https://git.io/vh2AY 45 | this.send = action => { 46 | let sideEffects; 47 | 48 | const updater = state => { 49 | const reduced = this.constructor.reducer(action, state); 50 | 51 | if (process.env.NODE_ENV !== "production") { 52 | if (typeof reduced === "undefined") { 53 | const name = this.displayName || this.constructor.name; 54 | throw new Error( 55 | name + 56 | "(...): `reducer` method returned `undefined`: did you " + 57 | "forget to handle this action? Please return `NoUpdate()`, " + 58 | "`Update(state)`, `SideEffects(fn)`, or " + 59 | "`UpdateWithSideEffects(state, fn)` instead." 60 | ); 61 | } 62 | } 63 | 64 | switch (reduced.type) { 65 | case NO_UPDATE: 66 | state = null; 67 | break; 68 | case UPDATE: 69 | state = reduced.state; 70 | break; 71 | case SIDE_EFFECTS: 72 | state = null; 73 | sideEffects = reduced.sideEffects; 74 | break; 75 | case UPDATE_WITH_SIDE_EFFECTS: 76 | state = reduced.state; 77 | sideEffects = reduced.sideEffects; 78 | break; 79 | default: { 80 | if (process.env.NODE_ENV !== "production") { 81 | const name = this.displayName || this.constructor.name; 82 | throw new Error( 83 | name + 84 | "(...): Return value of `reducer` method is not a valid " + 85 | "action. Please use: `NoUpdate()`, `Update(state)`, " + 86 | "`SideEffects(fn)`, or `UpdateWithSideEffects(state, fn)` " + 87 | "instead." 88 | ); 89 | } 90 | } 91 | } 92 | 93 | return state; 94 | }; 95 | 96 | setState.call(this, updater, () => sideEffects && sideEffects(this)); 97 | }; 98 | 99 | // Convenience method to create sender functions: Functions that send an 100 | // action to the reducer. The created actions will follow the naming 101 | // conventions of [flux-standard-actions]. 102 | // 103 | // If the sender is called with an argument (like an Event object for an 104 | // event callback), the first argument will be exposed as the `payload`. 105 | // Note that subsequent arguments to a sender are ignored for now. 106 | // 107 | // [flux-standard-actions]: https://github.com/redux-utilities/flux-standard-action 108 | this.createSender = type => { 109 | return payload => { 110 | this.send({ 111 | type, 112 | payload 113 | }); 114 | }; 115 | }; 116 | } 117 | }; 118 | } 119 | -------------------------------------------------------------------------------- /src/update-types.js: -------------------------------------------------------------------------------- 1 | export const NO_UPDATE = 0; 2 | export const UPDATE = 1; 3 | export const SIDE_EFFECTS = 2; 4 | export const UPDATE_WITH_SIDE_EFFECTS = 3; 5 | 6 | export function NoUpdate() { 7 | return { 8 | type: NO_UPDATE 9 | }; 10 | } 11 | 12 | export function Update(state) { 13 | return { 14 | type: UPDATE, 15 | state 16 | }; 17 | } 18 | 19 | export function SideEffects(sideEffects) { 20 | return { 21 | type: SIDE_EFFECTS, 22 | sideEffects 23 | }; 24 | } 25 | 26 | export function UpdateWithSideEffects(state, sideEffects) { 27 | return { 28 | type: UPDATE_WITH_SIDE_EFFECTS, 29 | state, 30 | sideEffects 31 | }; 32 | } 33 | -------------------------------------------------------------------------------- /type-definitions/ReComponent.d.ts: -------------------------------------------------------------------------------- 1 | import { Component } from "react"; 2 | 3 | export enum UpdateType { 4 | NO_UPDATE = 0, 5 | UPDATE = 1, 6 | SIDE_EFFECTS = 2, 7 | UPDATE_WITH_SIDE_EFFECTS = 3 8 | } 9 | 10 | export type SideEffect = (this: T) => any; 11 | 12 | export type NoUpdateAction = { 13 | type: UpdateType.NO_UPDATE; 14 | }; 15 | 16 | export type UpdateAction = { 17 | type: UpdateType.UPDATE; 18 | state: T; 19 | }; 20 | 21 | export type SideEffectsAction = { 22 | type: UpdateType.SIDE_EFFECTS; 23 | sideEffects: SideEffect; 24 | }; 25 | 26 | export type UpdateWithSideEffectsAction = { 27 | type: UpdateType.UPDATE_WITH_SIDE_EFFECTS; 28 | state: S; 29 | sideEffects: SideEffect; 30 | }; 31 | 32 | export type ReducerAction = 33 | | NoUpdateAction 34 | | UpdateAction 35 | | SideEffectsAction 36 | | UpdateWithSideEffectsAction; 37 | 38 | export function NoUpdate(): NoUpdateAction; 39 | 40 | export function Update(state: T): UpdateAction; 41 | 42 | export function SideEffects(sideEffect: SideEffect): SideEffectsAction; 43 | 44 | export function UpdateWithSideEffects( 45 | state: S, 46 | sideEffects: SideEffect 47 | ): UpdateWithSideEffectsAction; 48 | 49 | export type Action = { 50 | type: string; 51 | }; 52 | 53 | export class ReComponent

extends Component { 54 | static reducer( 55 | action: Action, 56 | state: TState 57 | ): ReducerAction; 58 | 59 | send(action: TAction): void; 60 | 61 | createSender( 62 | type: TAction 63 | ): ((payload: TPayload) => { type: TAction; payload: TPayload }); 64 | } 65 | 66 | export class RePureComponent

extends ReComponent {} 67 | -------------------------------------------------------------------------------- /type-definitions/ReComponent.js.flow: -------------------------------------------------------------------------------- 1 | /** 2 | * This file contains the Flow type definitions for ReComponent. 3 | * 4 | * @flow 5 | */ 6 | 7 | import * as React from "react"; 8 | 9 | declare opaque type UpdateType; 10 | 11 | declare export function NoUpdate(): {| type: UpdateType |}; 12 | declare export function Update(state: S): {| type: UpdateType, state: S |}; 13 | declare export function SideEffects( 14 | sideEffects: (T) => mixed 15 | ): {| type: UpdateType, sideEffects: T => mixed |}; 16 | declare export function UpdateWithSideEffects( 17 | state: S, 18 | sideEffects: (T) => mixed 19 | ): {| type: UpdateType, state: S, sideEffects: T => mixed |}; 20 | 21 | declare export class ReComponent< 22 | Props, 23 | State, 24 | Action: { +type: string } 25 | > extends React.Component { 26 | initialState(props: Props): State; 27 | 28 | static reducer( 29 | action: Action, 30 | state: State 31 | ): 32 | | {| type: UpdateType |} 33 | | {| type: UpdateType, state: $Shape |} 34 | | {| type: UpdateType, sideEffects: ($Subtype) => mixed |} 35 | | {| 36 | type: UpdateType, 37 | state: $Shape, 38 | sideEffects: ($Subtype) => mixed 39 | |}; 40 | 41 | send(action: Action): void; 42 | createSender(actionType: $ElementType): mixed => A; 43 | } 44 | 45 | declare export class RePureComponent< 46 | P, 47 | S, 48 | A: { +type: string } 49 | > extends ReComponent {} 50 | -------------------------------------------------------------------------------- /type-definitions/__tests__/ReComponent.js: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | 3 | import * as React from "react"; 4 | 5 | import { 6 | ReComponent, 7 | Update, 8 | NoUpdate, 9 | SideEffects, 10 | UpdateWithSideEffects 11 | } from "../../"; 12 | 13 | type Action = 14 | | {| type: "A" |} 15 | | {| type: "B" |} 16 | | {| type: "C" |} 17 | | {| type: "D" |}; 18 | 19 | class StateMismatch extends ReComponent<{}, { count: number }, Action> { 20 | // $ExpectError 21 | state = { invalid: "state" }; 22 | 23 | static reducer(action, state) { 24 | switch (action.type) { 25 | case "A": 26 | return Update({}); 27 | case "B": 28 | return Update({ count: 1 }); 29 | case "C": 30 | // $ExpectError - `count` should be `number` 31 | return Update({ count: "1" }); 32 | default: 33 | // $ExpectError - `invalid` is missing in State 34 | return Update({ invalid: "state" }); 35 | } 36 | } 37 | } 38 | 39 | class UpdateTypes extends ReComponent<{}, { count: number }, Action> { 40 | // Used to test the callback property of SideEffects 41 | someClassProperty: number; 42 | 43 | static reducer(action, state) { 44 | switch (action.type) { 45 | case "A": 46 | return NoUpdate(); 47 | case "B": 48 | return Update({ count: 1 }); 49 | case "C": 50 | return SideEffects((instance: UpdateTypes) => { 51 | instance.someClassProperty = 1; 52 | // $ExpectError - `instance.someClassProperty` has to be number 53 | instance.someClassProperty = "1"; 54 | }); 55 | default: 56 | return UpdateWithSideEffects({ count: 1 }, (instance: UpdateTypes) => { 57 | instance.someClassProperty = 1; 58 | // $ExpectError - `instance.someClassProperty` has to be number 59 | instance.someClassProperty = "1"; 60 | }); 61 | } 62 | } 63 | } 64 | 65 | class TypedActionTypes extends ReComponent< 66 | {}, 67 | { count: number }, 68 | {| type: "CLICK" |} 69 | > { 70 | handleClick = () => this.send({ type: "CLICK" }); 71 | 72 | static reducer(action, state) { 73 | switch (action.type) { 74 | case "CLICK": 75 | return NoUpdate(); 76 | default: 77 | return NoUpdate(); 78 | } 79 | } 80 | } 81 | 82 | const typedActionTypes = new TypedActionTypes(); 83 | typedActionTypes.send({ type: "CLICK" }); 84 | // $ExpectError - "CLACK" is invalid action type 85 | typedActionTypes.send({ type: "CLACK" }); 86 | // $ExpectError - invalid action 87 | typedActionTypes.send({}); 88 | 89 | typedActionTypes.handleClick(); 90 | // $ExpectError - `handleClick` expects no arguments 91 | typedActionTypes.handleClick({}); 92 | // $ExpectError - `handleClick` expects no arguments 93 | typedActionTypes.handleClick(1); 94 | 95 | // Flow can verify that we've handled every defined action type for us through 96 | // what is called [exhaustiveness testing]. 97 | // 98 | // This can be done by using the special type `empty` and casting to it in the 99 | // `default` or `else` branch. This will fail once Flow determines it can be 100 | // reached. 101 | // 102 | // [exhaustiveness testing]: https://blog.jez.io/flow-exhaustiveness/ 103 | const absurd = (x: empty): T => { 104 | throw new Error("absurd"); 105 | }; 106 | 107 | class ExhaustivelyTypedFailingActionTypes extends ReComponent< 108 | {}, 109 | { count: number }, 110 | {| type: "CLICK" |} | {| type: "CLACK" |} 111 | > { 112 | static reducer(action, state) { 113 | switch (action.type) { 114 | case "CLICK": 115 | return NoUpdate(); 116 | default: { 117 | // $ExpectError - should be unreachable 118 | absurd(action.type); 119 | return NoUpdate(); 120 | } 121 | } 122 | } 123 | } 124 | 125 | class ExhaustivelyTypedPassingActionTypes extends ReComponent< 126 | {}, 127 | { count: number }, 128 | { type: "CLICK" } | { type: "CLACK" } 129 | > { 130 | static reducer(action, state) { 131 | switch (action.type) { 132 | case "CLICK": 133 | return NoUpdate(); 134 | case "CLACK": 135 | return NoUpdate(); 136 | default: { 137 | absurd(action.type); 138 | return NoUpdate(); 139 | } 140 | } 141 | } 142 | } 143 | 144 | class FailingPayloadType extends ReComponent< 145 | {}, 146 | { count: number, awesome: boolean }, 147 | { type: "CLICK", payload: number } | { type: "CLACK", payload: boolean } 148 | > { 149 | // $ExpectError - `clicks` should be `number` 150 | handleClick = (clicks: boolean) => 151 | this.send({ type: "CLICK", payload: clicks }); 152 | // $ExpectError - `awesome` should be `boolean` 153 | handleClack = (awesome: number) => 154 | this.send({ type: "CLACK", payload: awesome }); 155 | 156 | static reducer(action, state) { 157 | switch (action.type) { 158 | case "CLICK": 159 | // $ExpectError - `awesome` should be `boolean`, but received `number` 160 | return Update({ awesome: action.payload }); 161 | case "CLACK": 162 | // $ExpectError - `count` should be `number`, but received `boolean` 163 | return Update({ count: action.payload }); 164 | default: { 165 | absurd(action.type); 166 | return NoUpdate(); 167 | } 168 | } 169 | } 170 | } 171 | 172 | class PassingPayloadType extends ReComponent< 173 | {}, 174 | { count: number, awesome: boolean }, 175 | { type: "CLICK", payload: number } | { type: "CLACK", payload: boolean } 176 | > { 177 | handleClick = (clicks: number) => 178 | this.send({ type: "CLICK", payload: clicks }); 179 | handleClack = (awesome: boolean) => 180 | this.send({ type: "CLACK", payload: awesome }); 181 | 182 | static reducer(action, state) { 183 | switch (action.type) { 184 | case "CLICK": 185 | return Update({ count: action.payload }); 186 | case "CLACK": 187 | return Update({ awesome: action.payload }); 188 | default: { 189 | absurd(action.type); 190 | return NoUpdate(); 191 | } 192 | } 193 | } 194 | } 195 | 196 | class CreateSenderTest extends ReComponent< 197 | {}, 198 | { count: number }, 199 | {| type: "CLICK" |} | {| type: "CLACK", payload: number |} 200 | > { 201 | handleClick = this.createSender("CLICK"); 202 | handleClack = this.createSender("CLACK"); 203 | // $ExpectError - "INVALID" is invalid action type 204 | handleFoo = this.createSender("INVALID"); 205 | // $ExpectError - invalid action type 206 | handleBar = this.createSender(); 207 | 208 | static reducer(action, state) { 209 | return NoUpdate(); 210 | } 211 | } 212 | 213 | const createSenderTest = new CreateSenderTest(); 214 | createSenderTest.send({ type: "CLICK" }); 215 | createSenderTest.send({ type: "CLACK", payload: 0 }); 216 | // $ExpectError - "INVALID" is invalid action type 217 | createSenderTest.send({ type: "INVALID" }); 218 | // $ExpectError - invalid action 219 | createSenderTest.send({}); 220 | // $ExpectError - invalid payload 221 | createSenderTest.send({ type: "CLACK", payload: "CLACK" }); 222 | 223 | // @TODO: Find out how we can assert the payload when using createSender 224 | createSenderTest.handleClick(); 225 | createSenderTest.handleClick({}); 226 | createSenderTest.handleClick(1); 227 | createSenderTest.handleClack(3); 228 | createSenderTest.handleClack(); 229 | createSenderTest.handleClick("sda"); 230 | -------------------------------------------------------------------------------- /type-definitions/ts-tests/exports.ts: -------------------------------------------------------------------------------- 1 | import * as recomponent from "react-recomponent"; 2 | import { 3 | NoUpdate, 4 | Update, 5 | SideEffects, 6 | UpdateWithSideEffects, 7 | ReComponent, 8 | RePureComponent 9 | } from "react-recomponent"; 10 | 11 | NoUpdate; // $ExpectType () => NoUpdateAction 12 | Update; // $ExpectType (state: T) => UpdateAction 13 | SideEffects; // $ExpectType (sideEffect: SideEffect) => SideEffectsAction 14 | UpdateWithSideEffects; // $ExpectType (state: S, sideEffects: SideEffect) => UpdateWithSideEffectsAction 15 | ReComponent; // $ExpectType typeof ReComponent 16 | RePureComponent; // $ExpectType typeof RePureComponent 17 | 18 | recomponent.NoUpdate; // $ExpectType () => NoUpdateAction 19 | recomponent.Update; // $ExpectType (state: T) => UpdateAction 20 | recomponent.SideEffects; // $ExpectType (sideEffect: SideEffect) => SideEffectsAction 21 | recomponent.UpdateWithSideEffects; // $ExpectType (state: S, sideEffects: SideEffect) => UpdateWithSideEffectsAction 22 | recomponent.ReComponent; // $ExpectType typeof ReComponent 23 | recomponent.RePureComponent; // $ExpectType typeof RePureComponent 24 | -------------------------------------------------------------------------------- /type-definitions/ts-tests/index.d.ts: -------------------------------------------------------------------------------- 1 | // TypeScript Version: 2.3 2 | -------------------------------------------------------------------------------- /type-definitions/ts-tests/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "target": "es5", 5 | "sourceMap": true, 6 | "noImplicitAny": true, 7 | "noImplicitThis": true, 8 | "strictFunctionTypes": true, 9 | "strictNullChecks": true, 10 | "lib": ["esnext"], 11 | "baseUrl": "../../", 12 | "paths": { "react-recomponent": ["."] } 13 | }, 14 | "exclude": [ 15 | "node_modules" 16 | ] 17 | } -------------------------------------------------------------------------------- /type-definitions/ts-tests/tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "dtslint/dtslint.json", 3 | "rules": { 4 | "no-useless-files": false, 5 | "no-duplicate-imports": false 6 | } 7 | } -------------------------------------------------------------------------------- /type-definitions/ts-tests/updateTypes.ts: -------------------------------------------------------------------------------- 1 | import { 2 | NoUpdate, 3 | Update, 4 | SideEffects, 5 | UpdateWithSideEffects 6 | } from "react-recomponent"; 7 | 8 | // $ExpectType NoUpdateAction 9 | NoUpdate(); 10 | 11 | // $ExpectType UpdateAction 12 | Update(4); 13 | 14 | // $ExpectType SideEffectsAction 15 | SideEffects(() => {}); 16 | // $ExpectError 17 | SideEffects({}); 18 | 19 | // $ExpectType UpdateWithSideEffectsAction 20 | UpdateWithSideEffects(4, () => {}); 21 | // $ExpectError 22 | UpdateWithSideEffects(4, {}); 23 | --------------------------------------------------------------------------------