Skip to content

Commit 1a0bc2e

Browse files
Prefer a static reducer (#4)
A `static` reducer will not only improve the code because it leaves less room for side effects, it will also make individual actions easier to test. Side effects still should have access to the instance (e.g. to access refs). To enable this, all side effects will now be called with a reference to the component as the first argument. Previous, class property reducers will still work until the 1.0.0 release to avoid making a breaking change in a minor version.
1 parent 6c4fa10 commit 1a0bc2e

9 files changed

+120
-47
lines changed

README.md

+23-13
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ A reducer component is used like a regular, stateful, React component with the d
3636

3737
- [Installation](#installation)
3838
- [Getting Started](#getting-started)
39-
- [Advantages Over `setState`](#advantages-over-setstate)
39+
- [FAQ](#faq)
4040
- [Advanced Usage](#advanced-usage)
4141
- [Side Effects](#side-effects)
4242
- [Handling Events](#handling-events)
@@ -67,7 +67,7 @@ class Counter extends ReComponent {
6767
this.state = { count: 0 };
6868
}
6969

70-
reducer(action, state) {
70+
static reducer(action, state) {
7171
switch (action.type) {
7272
case "CLICK":
7373
return Update({ count: state.count + 1 });
@@ -99,7 +99,9 @@ ReComponent comes with four different types of [effects](https://github.com/phil
9999

100100
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.
101101

102-
## Advantages Over `setState`
102+
## FAQ
103+
104+
### Advantages Over `setState`
103105

104106
The advantages are similar to those of [Redux](https://github.com/reduxjs/redux) or really any state management tool:
105107

@@ -109,6 +111,14 @@ The advantages are similar to those of [Redux](https://github.com/reduxjs/redux)
109111

110112
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).
111113

114+
### Why is the reducer `static`?
115+
116+
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:
117+
118+
1. You’re about to read class properties. In this case, make sure those properties are properly encapsulated in the state object.
119+
2. You’re about to write class properties. This is a side effect and should be handled using the `SideEffects(fn)` effect.
120+
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.
121+
112122
## Advanced Usage
113123

114124
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.
@@ -141,7 +151,7 @@ class Counter extends ReComponent {
141151
this.state = { count: 0 };
142152
}
143153

144-
reducer(action, state) {
154+
static reducer(action, state) {
145155
switch (action.type) {
146156
case "NO_UPDATE":
147157
return NoUpdate();
@@ -202,7 +212,7 @@ class Counter extends ReComponent {
202212
});
203213
}
204214

205-
reducer(action, state) {
215+
static reducer(action, state) {
206216
switch (action.type) {
207217
case "CLICK":
208218
return Update({
@@ -270,7 +280,7 @@ class Container extends ReComponent {
270280
this.state = { count: 0 };
271281
}
272282

273-
reducer(action, state) {
283+
static reducer(action, state) {
274284
switch (action.type) {
275285
case "CLICK":
276286
return Update({ count: state.count + 1 });
@@ -308,7 +318,7 @@ class UntypedActionTypes extends ReComponent<Props, State> {
308318
handleClick = this.createSender("CLICK");
309319
state = { count: 0 };
310320

311-
reducer(action, state) {
321+
static reducer(action, state) {
312322
switch (action.type) {
313323
case "CLICK":
314324
return Update({ count: state.count + 1 });
@@ -341,7 +351,7 @@ class TypedActionTypes extends ReComponent<Props, State, ActionTypes> {
341351
handleClick = this.createSender("CLICK");
342352
state = { count: 0 };
343353

344-
reducer(action, state) {
354+
static reducer(action, state) {
345355
switch (action.type) {
346356
case "CLICK":
347357
return Update({ count: state.count + 1 });
@@ -373,7 +383,7 @@ Check out the [type definition tests](https://github.com/philipp-spiess/react-re
373383

374384
- `ReComponent`
375385

376-
- `reducer(action, state): effect`
386+
- `static reducer(action, state): effect`
377387

378388
Translates an action into an effect. This is the main place to update your component‘s state.
379389

@@ -402,13 +412,13 @@ Check out the [type definition tests](https://github.com/philipp-spiess/react-re
402412

403413
Returning this effect will update the state. Internally, this will use `setState()` with an updater function.
404414

405-
- `SideEffects(fn)`
415+
- `SideEffects(this => mixed)`
406416

407-
Enqueues side effects to be run but will not update the component‘s state.
417+
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.
408418

409-
- `UpdateWithSideEffects(state, fn)`
419+
- `UpdateWithSideEffects(state, this => mixed)`
410420

411-
Updates the component‘s state and _then_ calls the side effect function.
421+
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.
412422

413423
## License
414424

__tests__/Context-test.js

+1-1
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ describe("ReComponent", () => {
4848
this.state = { count: 0 };
4949
}
5050

51-
reducer(action, state) {
51+
static reducer(action, state) {
5252
switch (action.type) {
5353
case "CLICK":
5454
return Update({ count: state.count + 1 });

__tests__/ReComponent-test.js

+35-3
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ describe("ReComponent", () => {
2626
this.state = { count: 0 };
2727
}
2828

29-
reducer(action, state) {
29+
static reducer(action, state) {
3030
switch (action.type) {
3131
case "CLICK":
3232
return Update({ count: state.count + 1 });
@@ -74,7 +74,7 @@ describe("ReComponent", () => {
7474
super();
7575
setState = () => this.setState({ some: "state" });
7676
}
77-
reducer() {}
77+
static reducer() {}
7878
render() {
7979
return null;
8080
}
@@ -98,7 +98,7 @@ describe("ReComponent", () => {
9898
click = this.createSender("CLICK");
9999
}
100100

101-
reducer(action, state) {
101+
static reducer(action, state) {
102102
return {};
103103
}
104104

@@ -113,4 +113,36 @@ describe("ReComponent", () => {
113113
process.env.NODE_ENV = originalNodeEnv;
114114
}
115115
});
116+
117+
it("warns when the reducer is not static", () => {
118+
let originalWarn = console.warn;
119+
try {
120+
console.warn = jest.fn();
121+
122+
let click;
123+
class ClassPropertyReducer extends ReComponent {
124+
constructor() {
125+
super();
126+
click = this.createSender("CLICK");
127+
}
128+
129+
reducer(action, state) {
130+
return NoUpdate();
131+
}
132+
133+
render() {
134+
return null;
135+
}
136+
}
137+
138+
ReactDOM.render(<ClassPropertyReducer />, container);
139+
expect(() => click()).not.toThrowError();
140+
141+
expect(console.warn).toHaveBeenCalledWith(
142+
"ClassPropertyReducer(...): Class property `reducer` methods are deprecated. Please upgrade to `static` reducers instead: https://github.com/philipp-spiess/react-recomponent#why-is-the-reducer-static"
143+
);
144+
} finally {
145+
console.warn = originalWarn;
146+
}
147+
});
116148
});

__tests__/ReComponentImmutable-test.js

+1-1
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ describe("ReComponentImmutable", () => {
3030
return State();
3131
}
3232

33-
reducer(action, state) {
33+
static reducer(action, state) {
3434
switch (action.type) {
3535
case "CLICK":
3636
return Update(state.update("count", count => count + 1));

__tests__/RePureComponent-test.js

+1-1
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ describe("RePureComponent", () => {
2525
this.state = { count: 0 };
2626
}
2727

28-
reducer(action, state) {
28+
static reducer(action, state) {
2929
switch (action.type) {
3030
case "CLICK":
3131
return Update({ count: state.count + 1 });

__tests__/UpdateTypes-test.js

+7-6
Original file line numberDiff line numberDiff line change
@@ -40,17 +40,18 @@ describe("UpdateTypes", () => {
4040
this.state = { count: 0 };
4141
}
4242

43-
reducer(action, state) {
43+
static reducer(action, state) {
4444
switch (action.type) {
4545
case "NO_UPDATE":
4646
return NoUpdate();
4747
case "UPDATE":
4848
return Update({ count: state.count + 1 });
4949
case "SIDE_EFFECTS":
50-
return SideEffects(() => sideEffectSpy());
50+
return SideEffects(sideEffectSpy);
5151
case "UPDATE_WITH_SIDE_EFFECTS":
52-
return UpdateWithSideEffects({ count: state.count + 1 }, () =>
53-
sideEffectSpy()
52+
return UpdateWithSideEffects(
53+
{ count: state.count + 1 },
54+
sideEffectSpy
5455
);
5556
case "INVALID":
5657
return {};
@@ -106,7 +107,7 @@ describe("UpdateTypes", () => {
106107
const instance = ReactDOM.render(<ReducerReturns />, container);
107108
sideEffects();
108109
expect(container.textContent).toEqual("You’ve clicked 0 times(s)");
109-
expect(sideEffectSpy).toHaveBeenCalled();
110+
expect(sideEffectSpy).toHaveBeenCalledWith(instance);
110111
});
111112

112113
it("does not re-render", () => {
@@ -121,7 +122,7 @@ describe("UpdateTypes", () => {
121122
const instance = ReactDOM.render(<ReducerReturns />, container);
122123
updateWithSideEffects();
123124
expect(container.textContent).toEqual("You’ve clicked 1 times(s)");
124-
expect(sideEffectSpy).toHaveBeenCalled();
125+
expect(sideEffectSpy).toHaveBeenCalledWith(instance);
125126
});
126127

127128
it("re-renders", () => {

src/re.js

+23-4
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,22 @@ export function Re(Component) {
1111
super(props);
1212

1313
if (process.env.NODE_ENV !== "production") {
14-
if (typeof this.reducer !== "function") {
15-
const name = this.displayName || this.constructor.name;
14+
const name = this.displayName || this.constructor.name;
15+
16+
const isStaticReducer = typeof this.constructor.reducer === "function";
17+
const isPropertyReducer = typeof this.reducer === "function";
18+
19+
// @TODO: Remove property reducer support with v1.0.0
20+
if (isPropertyReducer) {
21+
console.warn(
22+
name +
23+
"(...): Class property `reducer` methods are deprecated. Please " +
24+
"upgrade to `static` reducers instead: " +
25+
"https://github.com/philipp-spiess/react-recomponent#why-is-the-reducer-static"
26+
);
27+
}
28+
29+
if (!(isStaticReducer || isPropertyReducer)) {
1630
throw new Error(
1731
name +
1832
"(...): No `reducer` method found on the returned component " +
@@ -82,7 +96,12 @@ export function Re(Component) {
8296
let sideEffects;
8397

8498
const updater = state => {
85-
const reduced = this.reducer(action, state);
99+
let reduced;
100+
if (typeof this.reducer === "function") {
101+
reduced = this.reducer(action, state);
102+
} else {
103+
reduced = this.constructor.reducer(action, state);
104+
}
86105

87106
if (process.env.NODE_ENV !== "production") {
88107
if (typeof reduced === "undefined") {
@@ -129,7 +148,7 @@ export function Re(Component) {
129148
return state;
130149
};
131150

132-
setState.call(this, updater, () => sideEffects && sideEffects());
151+
setState.call(this, updater, () => sideEffects && sideEffects(this));
133152
};
134153

135154
// Convenience method to create sender functions: Functions that send an

type-definitions/ReComponent.js.flow

+9-10
Original file line numberDiff line numberDiff line change
@@ -7,19 +7,18 @@
77
import * as React from "react";
88

99
declare opaque type UpdateType;
10-
declare type fn = () => mixed;
1110

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

1413
declare export function NoUpdate(): {| type: UpdateType |};
1514
declare export function Update<S>(state: S): {| type: UpdateType, state: S |};
16-
declare export function SideEffects(
17-
sideEffects: fn
18-
): {| type: UpdateType, sideEffects: fn |};
19-
declare export function UpdateWithSideEffects<S>(
15+
declare export function SideEffects<T>(
16+
sideEffects: (T) => mixed
17+
): {| type: UpdateType, sideEffects: T => mixed |};
18+
declare export function UpdateWithSideEffects<S, T>(
2019
state: S,
21-
sideEffects: fn
22-
): {| type: UpdateType, state: S, sideEffects: fn |};
20+
sideEffects: (T) => mixed
21+
): {| type: UpdateType, state: S, sideEffects: T => mixed |};
2322

2423
declare export class ReComponent<
2524
Props,
@@ -28,14 +27,14 @@ declare export class ReComponent<
2827
> extends React.Component<Props, State> {
2928
initialState(props: Props): State;
3029

31-
reducer(
30+
static reducer(
3231
action: { type: ActionType },
3332
state: State
3433
):
3534
| {| type: UpdateType |}
3635
| {| type: UpdateType, state: $Shape<State> |}
37-
| {| type: UpdateType, sideEffects: fn |}
38-
| {| type: UpdateType, state: $Shape<State>, sideEffects: fn |};
36+
| {| type: UpdateType, sideEffects: ($Subtype<ReComponent<Props, State, ActionType>>) => mixed |}
37+
| {| type: UpdateType, state: $Shape<State>, sideEffects: ($Subtype<ReComponent<Props, State, ActionType>>) => mixed |};
3938

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

0 commit comments

Comments
 (0)