Skip to content

Commit a8472b0

Browse files
author
keenondrums
committed
Feature Added: @extend
1 parent 34bbc1c commit a8472b0

11 files changed

+618
-106
lines changed

README.md

Lines changed: 298 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,14 @@ Consider using it with [flux-action-class](https://github.com/keenondrums/flux-a
1818
- [With redux-actions](#with-redux-actions)
1919
- [Old school: action type constants](#old-school-action-type-constants)
2020
- [Integration with `immer`](#integration-with-immer)
21+
- [Reusing reducers](#reusing-reducers)
22+
- [Step 1](#step-1)
23+
- [Step 2](#step-2)
24+
- [How can I make shared reducer's logic dynamic?](#how-can-i-make-shared-reducers-logic-dynamic)
2125
- [In depth](#in-depth)
2226
- [When can we omit list of actions for `@Action`?](#when-can-we-omit-list-of-actions-for-action)
2327
- [Running several reducers for the same action](#running-several-reducers-for-the-same-action)
28+
- [How @Extend works?](#how-extend-works)
2429
- [How does it compare to ngrx-actions?](#how-does-it-compare-to-ngrx-actions)
2530

2631
<!-- END doctoc generated TOC please keep comment here to allow auto update -->
@@ -413,16 +418,305 @@ class ReducerCat extends ReducerClass<IReducerCatState> {
413418
@Action(ActionCatPlay, ActionCatBeAwesome)
414419
wasteEnegry(state: Immutable<IReducerCatState>, draft: IReducerCatState, action: ActionCatPlay | ActionCatBeAwesome) {
415420
draft.energy -= action.payload
421+
// Unfortunatelly, we can not omit `return` statement here due to how TypeScript handles `void`
422+
// https://github.com/Microsoft/TypeScript/wiki/FAQ#why-are-functions-returning-non-void-assignable-to-function-returning-void
423+
return undefined
416424
}
417425
}
418426

419427
const reducer = ReducerCat.create()
420428
```
421429

430+
> As you can see we still return `undefined` from the reducer even though we use [immer](https://github.com/mweststrate/immer) and mutate our draft. Unfortunately, we can not omit `return` statement here due to [how TypeScript handles `void`](https://github.com/Microsoft/TypeScript/wiki/FAQ#why-are-functions-returning-non-void-assignable-to-function-returning-void). We can not even write `return` (withour `undefined`), because TypeScript then presumes the method returns `void`.
431+
422432
> You might have noticed a new import - `Immutable`. It's just a cool name for [DeepReadonly type](https://github.com/gcanti/typelevel-ts#deepreadonly). You don't have to use it. The example above would work just fine if used just `IReducerCatState`. Yet it's recommended to wrap it with `Immutable` to ensure that you never mutate it.
423433
424434
> Actually it makes total sense to use `Immutable` for state of regular reducers as well to make sure you never modify state directly.
425435
436+
## Reusing reducers
437+
438+
So what if we want to share some logic between reducers?
439+
440+
### Step 1
441+
442+
Create a class with shared logic.
443+
444+
```ts
445+
import { Action, ReducerClassMixin } from 'reducer-class'
446+
447+
interface IHungryState {
448+
hungry: boolean
449+
}
450+
export class ReducerHungry<T extends IHungryState> extends ReducerClassMixin<T> {
451+
@Action(ActionHungry)
452+
hugry(state: T) {
453+
return {
454+
...state,
455+
hungry: true,
456+
}
457+
}
458+
459+
@Action(ActionFull)
460+
full(state: T) {
461+
return {
462+
...state,
463+
hungry: false,
464+
}
465+
}
466+
}
467+
```
468+
469+
> You might have noticed that made this class generic. We have to do that because we do not know what actual state we going to extend, we can only put a constraint on it to make sure it satisfies the structure we need. In other words, if we used `IHungryState` directly and returned `{ hungry: true }` (not `{ ...state, hungry: true }`) from `hungry` compiler wouldn't complain.
470+
471+
> You don't have to use `ReducerClassMixin` class. It's nothing but a convenience wrapper to make sure your class carries an index signature for type-safety. Alternatively you can use `IReducerClassConstraint` interface and `ReducerClassMethod` type.
472+
473+
<details>
474+
<summary>How to use `IReducerClassConstraint` interface and `ReducerClassMethod` type instead of `ReducerClassMixin` class</summary>
475+
476+
```ts
477+
import { Action, IReducerClassConstraint, ReducerClassMethod } from 'reducer-class'
478+
479+
interface IHungryState {
480+
hungry: boolean
481+
}
482+
export class ReducerHungry<T extends IHungryState> implements IReducerClassConstraint<T> {
483+
[methodName: string]: ReducerClassMethod<T>
484+
485+
@Action(ActionHungry)
486+
hugry(state: T) {
487+
return {
488+
...state,
489+
hungry: true,
490+
}
491+
}
492+
493+
@Action(ActionFull)
494+
full(state: T) {
495+
return {
496+
...state,
497+
hungry: false,
498+
}
499+
}
500+
}
501+
```
502+
503+
</details>
504+
505+
<details>
506+
<summary>JavaScript version</summary>
507+
508+
```js
509+
import { Action } from 'reducer-class'
510+
511+
export class ReducerHungry {
512+
@Action(ActionHungry)
513+
hugry(state) {
514+
return {
515+
...state,
516+
hungry: true,
517+
}
518+
}
519+
520+
@Action(ActionFull)
521+
full(state) {
522+
return {
523+
...state,
524+
hungry: false,
525+
}
526+
}
527+
}
528+
```
529+
530+
</details>
531+
532+
### Step 2
533+
534+
Use @Extend decorator.
535+
536+
```ts
537+
import { Action, Extend, ReducerClass } from 'reducer-class'
538+
539+
import { ReducerHungry } from 'shared'
540+
541+
interface ICatState {
542+
hugry: boolean
543+
enegry: number
544+
}
545+
@Extend<ICatState>(ReducerHungry)
546+
class CatReducer extends ReducerClass<ICatState> {
547+
initialState = {
548+
energy: 100,
549+
}
550+
551+
@Action
552+
addEnergy(state: ICatState, action: ActionCatEat) {
553+
return {
554+
energy: state.energy + action.payload,
555+
}
556+
}
557+
558+
@Action(ActionCatPlay, ActionCatBeAwesome)
559+
wasteEnegry(state: ICatState, action: ActionCatPlay | ActionCatBeAwesome) {
560+
return {
561+
energy: state.energy - action.payload,
562+
}
563+
}
564+
}
565+
566+
const reducer = ReducerCat.create()
567+
```
568+
569+
> @Extend can accept as many arguments as you want.
570+
571+
Now our cat reducer uses `wasteEnegry` to handle actions `ActionCatPlay`, `ActionCatBeAwesome`, `addEnergy` to handle `ActionCatEat` and inherits `hugry` and `full` methods to handle `ActionHungry` and `ActionFull` from `ReducerHungry`.
572+
573+
<details>
574+
<summary>JavaScript version</summary>
575+
576+
```js
577+
import { Action, Extend, ReducerClass } from 'reducer-class'
578+
579+
import { ReducerHungry } from 'shared'
580+
581+
@Extend(ReducerHungry)
582+
class CatReducer extends ReducerClass {
583+
initialState = {
584+
energy: 100,
585+
}
586+
587+
@Action(ActionCatEat)
588+
addEnergy(state, action) {
589+
return {
590+
energy: state.energy + action.payload,
591+
}
592+
}
593+
594+
@Action(ActionCatPlay, ActionCatBeAwesome)
595+
wasteEnegry(state, action) {
596+
return {
597+
energy: state.energy - action.payload,
598+
}
599+
}
600+
}
601+
602+
const reducer = ReducerCat.create()
603+
```
604+
605+
</details>
606+
607+
### How can I make shared reducer's logic dynamic?
608+
609+
You can use class factories.
610+
611+
```ts
612+
import { Action, Extend, ReducerClass, ReducerClassMixin } from 'reducer-class'
613+
614+
interface IHungryState {
615+
hungry: boolean
616+
}
617+
export const makeReducerHungry = <T extends IHungryState>(actionHungry, actionFull) => {
618+
class Extender1 extends ReducerClassMixin<T> {
619+
@Action(actionHungry)
620+
hugry(state: T) {
621+
return {
622+
...state,
623+
hungry: true,
624+
}
625+
}
626+
627+
@Action(actionFull)
628+
full(state: T) {
629+
return {
630+
...state,
631+
hungry: false,
632+
}
633+
}
634+
}
635+
return Extender1
636+
}
637+
638+
interface ICatState {
639+
hugry: boolean
640+
enegry: number
641+
}
642+
@Extend<ICatState>(makeReducerHungry(ActionCatPlay, ActionCatEat))
643+
class CatReducer extends ReducerClass<ICatState> {
644+
initialState = {
645+
energy: 100,
646+
}
647+
648+
@Action
649+
addEnergy(state: ICatState, action: ActionCatEat) {
650+
return {
651+
energy: state.energy + action.payload,
652+
}
653+
}
654+
655+
@Action
656+
wasteEnegry(state: ICatState, action: ActionCatPlay) {
657+
return {
658+
energy: state.energy - action.payload,
659+
}
660+
}
661+
}
662+
663+
const reducer = ReducerCat.create()
664+
```
665+
666+
<details>
667+
<summary>JavaScript version</summary>
668+
669+
```js
670+
import { Action, Extend, ReducerClass } from 'reducer-class'
671+
672+
interface IHungryState {
673+
hungry: boolean;
674+
}
675+
export const makeReducerHungry = (actionHungry, actionFull) =>
676+
class {
677+
@Action(actionHungry)
678+
hugry(state) {
679+
return {
680+
...state,
681+
hungry: true,
682+
}
683+
}
684+
685+
@Action(actionFull)
686+
full(state) {
687+
return {
688+
...state,
689+
hungry: false,
690+
}
691+
}
692+
}
693+
694+
@Extend(makeReducerHungry(ActionCatPlay, ActionCatEat))
695+
class CatReducer extends ReducerClass {
696+
initialState = {
697+
energy: 100,
698+
}
699+
700+
@Action(ActionCatEat)
701+
addEnergy(state, action) {
702+
return {
703+
energy: state.energy + action.payload,
704+
}
705+
}
706+
707+
@Action(ActionCatPlay)
708+
wasteEnegry(state, action) {
709+
return {
710+
energy: state.energy - action.payload,
711+
}
712+
}
713+
}
714+
715+
const reducer = ReducerCat.create()
716+
```
717+
718+
</details>
719+
426720
## In depth
427721

428722
### When can we omit list of actions for `@Action`?
@@ -471,6 +765,10 @@ const res2 = reducer(res1, new ActionCatEat(5))
471765
console.log(res2) // logs 135: 130 - previous value, 5 is added by addEnergy
472766
```
473767

768+
### How @Extend works?
769+
770+
It iterates over its arguments and copies their methods and corresponding metadata to a prototype of our target reducer class.
771+
474772
## How does it compare to [ngrx-actions](https://github.com/amcdnl/ngrx-actions)?
475773

476774
1. Stricter typings. Now you'll never forget to add initial state, return a new state from your reducer and accidentally invoke `immer` as a result and etc.

index.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
export { Action } from './src/decorator-action'
2+
export { Extend, ReducerClassMixin } from './src/decorator-extend'
23
export { METADATA_KEY_ACTION } from './src/constants'
34
export * from './src/errors'
45
export { ReducerClass } from './src/reducer-class'
5-
export { Immutable } from './src/reducer-class-helpers'
6+
export { Immutable, IReducerClassConstraint, ReducerClassMethod } from './src/reducer-class-helpers'

0 commit comments

Comments
 (0)