Skip to content

Commit

Permalink
Fix flow types (#7)
Browse files Browse the repository at this point in the history
Fixes #6

- Requires `Action` instead of `ActionType` as a generic parameter of `ReComponent` and `RePureComponent`
- makes sure the type of the `action.payload` is preserved throughout the component's API and reducer

Unfortunately, in order to ensure proper type-checking I had to remove `createSender` API.
I simply didn't manage to come up with typing that would ensure the correct `payload` typing, while maintaining the same API of `createSender`. Please let me know if you see a solution for it.

Even though it has to be removed, I don't think it would make the ergonomics of `ReComponent` significantly worse:
```js
this.handleClick = this.createSender("CLICK");
```
becomes
```js
this.handleClick = () => this.send({ type: "CLICK" });
```
or
```js
this.handleClick = (clicks: number) => this.send({ type: "CLICK", payload: clicks });
```

NOTE: I did not fix documentation (remove all mentions of `createSender` and fix the examples) yet. I would like to wait for the code review first just to know if you are OK with this breaking change. If you decide to proceed with these changes I will make sure the docs are updated as well
  • Loading branch information
vovacodes authored and philipp-spiess committed Jul 22, 2018
1 parent e6eb7de commit ef07fe9
Show file tree
Hide file tree
Showing 7 changed files with 148 additions and 113 deletions.
64 changes: 24 additions & 40 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -368,56 +368,33 @@ If you鈥榬e 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鈥榬e 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<Props, State> {
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 (
<button onClick={this.handleClick}>
You鈥檝e clicked this {this.state.count} times(s)
</button>
);
}
}
```
class TypedActions extends ReComponent<Props, State, Action> {
// 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<Props, State, ActionTypes> {
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();
}
Expand All @@ -426,9 +403,12 @@ class TypedActionTypes extends ReComponent<Props, State, ActionTypes> {

render() {
return (
<button onClick={this.handleClick}>
You鈥檝e clicked this {this.state.count} times(s)
</button>
<React.Fragment>
<button onClick={this.handleClick}>
You鈥檝e clicked this {this.state.count} times(s)
</button>
<Input onValueChange={this.handleValueUpdate} />
<React.Fragment/>
);
}
}
Expand All @@ -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

Expand Down
2 changes: 1 addition & 1 deletion __tests__/Context-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
}

Expand Down
13 changes: 3 additions & 10 deletions __tests__/ReComponent-test.js
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -123,7 +116,7 @@ describe("ReComponent", () => {
class ClassPropertyReducer extends ReComponent {
constructor() {
super();
click = this.createSender("CLICK");
click = () => this.send({ type: "CLICK" });
}

reducer(action, state) {
Expand Down
2 changes: 1 addition & 1 deletion __tests__/ReComponentImmutable-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
13 changes: 7 additions & 6 deletions __tests__/UpdateTypes-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
}

Expand Down
15 changes: 6 additions & 9 deletions type-definitions/ReComponent.js.flow
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,6 @@ import * as React from "react";

declare opaque type UpdateType;

export type Sender<AT, A = mixed> = (a: A) => { action: AT, payload: A };

declare export function NoUpdate(): {| type: UpdateType |};
declare export function Update<S>(state: S): {| type: UpdateType, state: S |};
declare export function SideEffects<T>(
Expand All @@ -22,13 +20,13 @@ declare export function UpdateWithSideEffects<S, T>(

declare export class ReComponent<
Props,
State = void,
ActionType = string
State,
Action: { +type: string },
> extends React.Component<Props, State> {
initialState(props: Props): State;

static reducer(
action: { type: ActionType },
action: Action,
state: State
):
| {| type: UpdateType |}
Expand All @@ -40,13 +38,12 @@ declare export class ReComponent<
sideEffects: ($Subtype<this>) => mixed
|};

send(action: { type: ActionType, payload?: mixed }): void;

createSender<AT: ActionType>(actionType: ActionType): Sender<AT>;
send(action: Action): void;
createSender<A: Action>(actionType: $ElementType<A, 'type'>): (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<P, S, A> extends ReComponent<P, S, A> {}
declare export class RePureComponent<P, S, A: { +type: string }> extends ReComponent<P, S, A> {}
Loading

0 comments on commit ef07fe9

Please sign in to comment.