diff --git a/README.md b/README.md index afb5cb3..6d4cc63 100644 --- a/README.md +++ b/README.md @@ -368,56 +368,33 @@ If you‘re having troubles understanding this example, I recommend the fantasti _[Flow][] is a static type checker for JavaScript. This section is only relevant for you if you‘re using Flow in your application._ -ReComponent comes with first class Flow support built in. By default, a ReComponent will behave like a regular Component and will require props and state to be typed: +ReComponent comes with first class Flow support built in. +When extending `ReComponent`, in addition to the `Props` and `State` types required by regular `React.Component` +we need to specify the third generic parameter which should be a union of all actions used by the component. +This ensures type-safety everywhere in the code of the component where the actions are used and +even allows [exhaustiveness testing] to verify that every action is indeed handled. ```js import * as React from "react"; import { ReComponent, Update } from "react-recomponent"; type Props = {}; -type State = { count: number }; +type State = { count: number, value: string }; +type Action = {| type: "CLICK" |} | {| type: "UPDATE_VALUE", payload: string |}; -class UntypedActionTypes extends ReComponent { - handleClick = this.createSender("CLICK"); - state = { count: 0 }; - - static reducer(action, state) { - switch (action.type) { - case "CLICK": - return Update({ count: state.count + 1 }); - default: - return NoUpdate(); - } - } - - render() { - return ( - - ); - } -} -``` +class TypedActions extends ReComponent { + // NOTE: we use `this.send` API because it ensures type-safety for action's `payload` + handleClick = () => this.send({ type: "CLICK" }); + handleUpdateValue = (newValue: string) => this.send({ type: "UPDATE_VALUE", payload: newValue }); -Without specifying our action types any further, we will allow all `string` values. It is, however, recommended that we type all action types using a union of string literals. This will further tighten the type checks and will even allow [exhaustiveness testing] to verify that every action is indeed handled. - -```js -import * as React from "react"; -import { ReComponent, Update } from "react-recomponent"; - -type Props = {}; -type State = { count: number }; -type ActionTypes = "CLICK"; - -class TypedActionTypes extends ReComponent { - handleClick = this.createSender("CLICK"); state = { count: 0 }; static reducer(action, state) { switch (action.type) { case "CLICK": return Update({ count: state.count + 1 }); + case "UPDATE_VALUE": + return Update({ value: action.payload }); default: { return NoUpdate(); } @@ -426,9 +403,12 @@ class TypedActionTypes extends ReComponent { render() { return ( - + + + + ); } } @@ -438,7 +418,11 @@ Check out the [type definition tests](https://github.com/philipp-spiess/react-re **Known Limitations With Flow:** -- While it is possible to exhaustively type check the reducer, Flow will still require every branch to return an effect. This is why the above examples returns `NoUpdate()` even though the branch can never be reached. +- `this.send` API for sending actions is preferred over `this.createSender`. This is because `this.createSender` + effectively types the payload as `any` (limitation we can't overcome for now), whereas `this.send` provides full type-safety + for actions +- While it is possible to exhaustively type check the reducer, Flow will still require every branch to return an effect. + This is why the above examples returns `NoUpdate()` even though the branch can never be reached. ## API Reference diff --git a/__tests__/Context-test.js b/__tests__/Context-test.js index 09a4ca6..1d225c7 100644 --- a/__tests__/Context-test.js +++ b/__tests__/Context-test.js @@ -44,7 +44,7 @@ describe("ReComponent", () => { class Container extends ReComponent { constructor() { super(); - this.handleClick = this.createSender("CLICK"); + this.handleClick = () => this.send({ type: "CLICK" }); this.state = { count: 0 }; } diff --git a/__tests__/ReComponent-test.js b/__tests__/ReComponent-test.js index 9319c8b..2802775 100644 --- a/__tests__/ReComponent-test.js +++ b/__tests__/ReComponent-test.js @@ -1,14 +1,7 @@ import React from "react"; import ReactDOM from "react-dom"; -import { Record } from "immutable"; -import { - ReComponent, - NoUpdate, - Update, - SideEffects, - UpdateWithSideEffects -} from "../src"; +import { ReComponent, NoUpdate, Update } from "../src"; import { click, withConsoleMock } from "./helpers"; @@ -95,7 +88,7 @@ describe("ReComponent", () => { class Example extends ReComponent { constructor() { super(); - click = this.createSender("CLICK"); + click = () => this.send({ type: "CLICK" }); } static reducer(action, state) { @@ -123,7 +116,7 @@ describe("ReComponent", () => { class ClassPropertyReducer extends ReComponent { constructor() { super(); - click = this.createSender("CLICK"); + click = () => this.send({ type: "CLICK" }); } reducer(action, state) { diff --git a/__tests__/ReComponentImmutable-test.js b/__tests__/ReComponentImmutable-test.js index 44f5709..a61723f 100644 --- a/__tests__/ReComponentImmutable-test.js +++ b/__tests__/ReComponentImmutable-test.js @@ -23,7 +23,7 @@ describe("ReComponentImmutable", () => { class Example extends ReComponent { constructor() { super(); - this.handleClick = this.createSender("CLICK"); + this.handleClick = () => this.send({ type: "CLICK" }); } initialImmutableState(props) { diff --git a/__tests__/UpdateTypes-test.js b/__tests__/UpdateTypes-test.js index b9e0977..2a5b696 100644 --- a/__tests__/UpdateTypes-test.js +++ b/__tests__/UpdateTypes-test.js @@ -31,12 +31,13 @@ describe("UpdateTypes", () => { class ReducerReturns extends ReComponent { constructor() { super(); - noUpdate = this.createSender("NO_UPDATE"); - update = this.createSender("UPDATE"); - sideEffects = this.createSender("SIDE_EFFECTS"); - updateWithSideEffects = this.createSender("UPDATE_WITH_SIDE_EFFECTS"); - invalid = this.createSender("INVALID"); - unhandled = this.createSender("UNHANDLED"); + noUpdate = () => this.send({ type: "NO_UPDATE" }); + update = () => this.send({ type: "UPDATE" }); + sideEffects = () => this.send({ type: "SIDE_EFFECTS" }); + updateWithSideEffects = () => + this.send({ type: "UPDATE_WITH_SIDE_EFFECTS" }); + invalid = () => this.send({ type: "INVALID" }); + unhandled = () => this.send({ type: "UNHANDLED" }); this.state = { count: 0 }; } diff --git a/type-definitions/ReComponent.js.flow b/type-definitions/ReComponent.js.flow index 4739bb1..a7a2e4f 100644 --- a/type-definitions/ReComponent.js.flow +++ b/type-definitions/ReComponent.js.flow @@ -8,8 +8,6 @@ import * as React from "react"; declare opaque type UpdateType; -export type Sender = (a: A) => { action: AT, payload: A }; - declare export function NoUpdate(): {| type: UpdateType |}; declare export function Update(state: S): {| type: UpdateType, state: S |}; declare export function SideEffects( @@ -22,13 +20,13 @@ declare export function UpdateWithSideEffects( declare export class ReComponent< Props, - State = void, - ActionType = string + State, + Action: { +type: string }, > extends React.Component { initialState(props: Props): State; static reducer( - action: { type: ActionType }, + action: Action, state: State ): | {| type: UpdateType |} @@ -40,13 +38,12 @@ declare export class ReComponent< sideEffects: ($Subtype) => mixed |}; - send(action: { type: ActionType, payload?: mixed }): void; - - createSender(actionType: ActionType): Sender; + send(action: Action): void; + createSender(actionType: $ElementType): (mixed) => A; // This type is only used when initialState returns an Immutable.js data type. // Consider this API unstable. +immutableState: State; } -declare export class RePureComponent extends ReComponent {} +declare export class RePureComponent extends ReComponent {} diff --git a/type-definitions/__tests__/ReComponent.js b/type-definitions/__tests__/ReComponent.js index 1e2bf53..f957b16 100644 --- a/type-definitions/__tests__/ReComponent.js +++ b/type-definitions/__tests__/ReComponent.js @@ -11,35 +11,9 @@ import { UpdateWithSideEffects } from "../../"; -class UntypedActionTypes extends ReComponent<{}, { count: number }> { - handleClick = this.createSender("CLICK"); - // $ExpectError - handleFoo = this.createSender(); +type Action = {| type: "A" |} | {| type: "B" |} | {| type: "C" |} | {| type: "D" |} - state = { count: 0 }; - - static reducer(action, state) { - switch (action.type) { - case "CLICK": - return Update({ count: state.count + 1 }); - default: - return NoUpdate(); - } - } -} -const untypedActionTypes = new UntypedActionTypes(); -untypedActionTypes.send({ type: "CLICK" }); -untypedActionTypes.send({ type: "CLACK" }); -// $ExpectError -untypedActionTypes.send({}); - -untypedActionTypes.handleClick(); -untypedActionTypes.handleClick({}); -untypedActionTypes.handleClick(1); -// $ExpectError -untypedActionTypes.handleClick({}, {}); - -class StateMismatch extends ReComponent<{}, { count: number }> { +class StateMismatch extends ReComponent<{}, { count: number }, Action> { // $ExpectError state = { invalid: "state" }; @@ -50,16 +24,16 @@ class StateMismatch extends ReComponent<{}, { count: number }> { case "B": return Update({ count: 1 }); case "C": - // $ExpectError + // $ExpectError - `count` should be `number` return Update({ count: "1" }); default: - // $ExpectError + // $ExpectError - `invalid` is missing in State return Update({ invalid: "state" }); } } } -class UpdateTypes extends ReComponent<{}, { count: number }> { +class UpdateTypes extends ReComponent<{}, { count: number }, Action> { // Used to test the callback property of SideEffects someClassProperty: number; @@ -72,13 +46,13 @@ class UpdateTypes extends ReComponent<{}, { count: number }> { case "C": return SideEffects((instance: UpdateTypes) => { instance.someClassProperty = 1; - // $ExpectError + // $ExpectError - `instance.someClassProperty` has to be number instance.someClassProperty = "1"; }); default: return UpdateWithSideEffects({ count: 1 }, (instance: UpdateTypes) => { instance.someClassProperty = 1; - // $ExpectError + // $ExpectError - `instance.someClassProperty` has to be number instance.someClassProperty = "1"; }); @@ -86,12 +60,12 @@ class UpdateTypes extends ReComponent<{}, { count: number }> { } } -class TypedActionTypes extends ReComponent<{}, { count: number }, "CLICK"> { - handleClick = this.createSender("CLICK"); - // $ExpectError - handleFoo = this.createSender("CLACK"); - // $ExpectError - handleBar = this.createSender(); +class TypedActionTypes extends ReComponent< + {}, + { count: number }, + {| type: 'CLICK' |} +> { + handleClick = () => this.send({ type: 'CLICK' }); static reducer(action, state) { switch (action.type) { @@ -105,16 +79,16 @@ class TypedActionTypes extends ReComponent<{}, { count: number }, "CLICK"> { const typedActionTypes = new TypedActionTypes(); typedActionTypes.send({ type: "CLICK" }); -// $ExpectError +// $ExpectError - "CLACK" is invalid action type typedActionTypes.send({ type: "CLACK" }); -// $ExpectError +// $ExpectError - invalid action typedActionTypes.send({}); typedActionTypes.handleClick(); +// $ExpectError - `handleClick` expects no arguments typedActionTypes.handleClick({}); +// $ExpectError - `handleClick` expects no arguments typedActionTypes.handleClick(1); -// $ExpectError -typedActionTypes.handleClick({}, {}); // Flow can verify that we've handled every defined action type for us through // what is called [exhaustiveness testing]. @@ -127,27 +101,29 @@ typedActionTypes.handleClick({}, {}); const absurd = (x: empty): T => { throw new Error("absurd"); }; + class ExhaustivelyTypedFailingActionTypes extends ReComponent< {}, { count: number }, - "CLICK" | "CLACK" + {| type: 'CLICK' |} | {| type: 'CLACK' |} > { static reducer(action, state) { switch (action.type) { case "CLICK": return NoUpdate(); default: { - // $ExpectError + // $ExpectError - should be unreachable absurd(action.type); return NoUpdate(); } } } } + class ExhaustivelyTypedPassingActionTypes extends ReComponent< {}, { count: number }, - "CLICK" | "CLACK" + { type: "CLICK" } | { type: "CLACK" } > { static reducer(action, state) { switch (action.type) { @@ -162,3 +138,87 @@ class ExhaustivelyTypedPassingActionTypes extends ReComponent< } } } + + +class FailingPayloadType extends ReComponent< + {}, + { count: number, awesome: boolean }, + { type: "CLICK", payload: number } | { type: "CLACK", payload: boolean } +> { + // $ExpectError - `clicks` should be `number` + handleClick = (clicks: boolean) => this.send({ type: 'CLICK', payload: clicks }); + // $ExpectError - `awesome` should be `boolean` + handleClack = (awesome: number) => this.send({ type: 'CLACK', payload: awesome }); + + static reducer(action, state) { + switch (action.type) { + case "CLICK": + // $ExpectError - `awesome` should be `boolean`, but received `number` + return Update({ awesome: action.payload }); + case "CLACK": + // $ExpectError - `count` should be `number`, but received `boolean` + return Update({ count: action.payload }); + default: { + absurd(action.type); + return NoUpdate(); + } + } + } +} + +class PassingPayloadType extends ReComponent< + {}, + { count: number, awesome: boolean }, + { type: "CLICK", payload: number } | { type: "CLACK", payload: boolean } + > { + handleClick = (clicks: number) => this.send({ type: 'CLICK', payload: clicks }); + handleClack = (awesome: boolean) => this.send({ type: 'CLACK', payload: awesome }); + + static reducer(action, state) { + switch (action.type) { + case "CLICK": + return Update({ count: action.payload }); + case "CLACK": + return Update({ awesome: action.payload }); + default: { + absurd(action.type); + return NoUpdate(); + } + } + } +} + +class CreateSenderTest extends ReComponent< + {}, + { count: number }, + {| type: 'CLICK' |} | {| type: 'CLACK', payload: number |} +> { + handleClick = this.createSender("CLICK"); + handleClack = this.createSender("CLACK"); + // $ExpectError - "INVALID" is invalid action type + handleFoo = this.createSender("INVALID"); + // $ExpectError - invalid action type + handleBar = this.createSender(); + + static reducer(action, state) { + return NoUpdate(); + } +} + +const createSenderTest = new CreateSenderTest(); +createSenderTest.send({ type: "CLICK" }); +createSenderTest.send({ type: "CLACK", payload: 0 }); +// $ExpectError - "INVALID" is invalid action type +createSenderTest.send({ type: "INVALID" }); +// $ExpectError - invalid action +createSenderTest.send({}); +// $ExpectError - invalid payload +createSenderTest.send({ type: "CLACK", payload: "CLACK" }); + +// @TODO: Find out how we can assert the payload when using createSender +createSenderTest.handleClick(); +createSenderTest.handleClick({}); +createSenderTest.handleClick(1); +createSenderTest.handleClack(3); +createSenderTest.handleClack(); +createSenderTest.handleClick("sda");