Skip to content

Instantly share code, notes, and snippets.

@amcdnl
Last active January 31, 2021 17:05
Show Gist options
  • Save amcdnl/d625a74fda50083165e4b7d983beb513 to your computer and use it in GitHub Desktop.
Save amcdnl/d625a74fda50083165e4b7d983beb513 to your computer and use it in GitHub Desktop.

Info

Goals

  • Modern TS
  • Little boilerplate
  • Better types

Features

  • Selectors
  • Easy Testing
  • Crud API Wrapper
  • ASYNC Operation
  • Redux Debugger Integration
  • Lazy Loadable Stores
  • Meta/High Order Stores
  • Schematics
  • Keep read / writes decoupled
  • Route Reducer
  • Page Title Reducer

The Problem

Redux is a great state mangement system, it provides:

  • Decoupling state from components for better responsibility delegation
  • Global state that components can share anywhere in the component tree
  • Hot reload capibility
  • Time travel capability

Redux was created for React which doesn't use Observables or TypeScript in its paradigms. NGRX is evolution of Redux that adds observables and Angular DI but it still keeps all the same paradigms as Redux.

Since Redux's paradigms weren't designed for these new technologies it has some shortfalls that we can exploit with this new capabilities like: types, decorators, observables, di.

Lets take the following example of making a simple request to populate a store with a HTTP request using NGRX:

actions.ts

export const enum ActionTypes {
    LOAD = 'Loading',
    LOAD_SUCCESS = 'Load Success',
    LOAD_ERROR = 'Load Error'
}

export class Load implements Action {
    readonly type = ActionTypes.LOAD;
    constructor(public payload?: { limit?: number; offset?: number; filter?: any[]; sort?: any[] }) {}
}

export class LoadSuccess implements Action {
    readonly type = ActionTypes.LOAD_PLATFORMS_SUCCESS;
    constructor(public payload: MyModel) {}
}

export class LoadError implements Action {
    readonly type = ActionTypes.LOAD_ERROR;
    constructor(public payload: MyError) {}
}

reducer.ts

export interface PizzaState {
  loading: boolean;
  items: any[];
  errors: any[];
}

const initialState: PizzaState = {
    loading: false,
    items: [],
    errors: null
};

export function pizzaReducer(state: PizzaState = initialState, action: PizzaActions) {
    switch (action.type) {
        case ActionTypes.LOAD:
            return { ...state, loading: true };
        case ActionTypes.LOAD_SUCCESS:
            return { ...state, loading: false, items: action.payload };
        case ActionTypes.LOAD_ERROR:
            return { ...state, loading: false, errors: action.payload };
        default:
            return state;
    }
}

effects.ts

@Injectable()
export class PizzaEffects {
    constructor(
        private store: Store<any>,
        private update$: Actions,
        private myService: MyService
    ) {}

    @Effect()
    loadPizzas$ = this.update$.ofType(ActionTypes.LOAD).pipe(
        switchMap(state => this.myService.query()),
        map(res => new LoadSuccess(res)));

This decouplation of read/write and async/sync state is very powerful but it creates a lot of code to do something rather simple. The goal of this project is to reduce this boilerplate down to simple dispatch and state action system.

Challenges

Classes

People will want to abuse classes and might do bad things like put state in the class manually/etc.

How do we handle lazy loading?

Whats the best way to handle lazy loading?

Meta Reduces

Could be accomplished with inheritance (ppl w/ probably not like) or passed via decorator prop.

@Store<PizzaState>({
  pizzas: [],
  cooking: false
})
export class PizzaReducer extends DoughReducer { }

-- or --

@Store<PizzaState>({
  pizzas: [],
  cooking: false
}, DoughReducer, ...)
export class PizzaReducer  { }

Effects

Do we keep effects decoupled, could we integrate them, or do we even need them now? vuex integrates them which could be interesting.

@Store<PizzaState>({
  pizzas: [],
  cooking: false
})
export class PizzaReducer  {

  // Could make injectable now that its a class
  constructor(private pizzaSvc: PizzaService) {}

  // Ability to return sync and async state from same action
  @Action(BuyPizza)
  buyPizza({ dispatch }, pizza) { 
    // not really a fan of passsing dispatch like this but not sure better way w/o inheritance
    // this how vuex actually does it though...
    
    const state = { ...this.state, cooking: true };
 
    this.pizzaSvc.order(pizza)
      .pipe(tap(res => dispatch(new PizzaOrdered(res)));
  
    return state;
  }
  
  // Make actions accept observable responses
  @Action(BuyPizza)
  buyPizza$ = this.pizzaSvc.order(pizza)
    .pipe(mergeMap(pizzas => { ...this.state, pizzas }))
    
  // Maybe make a commit fn?
  @Action(CookPizza)
  cookPizza({ state, commit }, payload: Pizza): PizzaState {
    commit({ ...this.state, cooking: true });
    return this.pizzaService.get(payload).map(res => { ...this.state, cooking: false });
  }

}

Key things:

  • Should be able to return observables
  • Should be able to do both sync and async ops in one action

Crud

Make a store that automatically has all crud ops and can hook up to a service contract.

export class PizzaService implements EntityService {
  query();
  get(id);
  create(body);
  update(body);
  delete(id);
}

...then...

@EntityStore(PizzaService, { ... })
export class PizzaReducer {}

...then...

this.store.dispatch(new CreateEntity(body));

Question is how do we associate the generic entity to the store we care about w/o having to make a bunch of boilerplate to tie them together.

Route Reducer

Example of current ngrx route reducer:

@Injectable()
export class RouterEffects {
    constructor(private actions$: Actions, private router: Router, private location: Location) {}

    @Effect({ dispatch: false })
    navigate$ = this.actions$
        .ofType(RouterActionTypes.GO)
        .pipe(
            map((action: RouterGo) => action.payload),
            tap(({ path, queryParams, extras }) => this.router.navigate(path, { queryParams, ...extras }))
        );

    @Effect({ dispatch: false })
    navigateBack$ = this.actions$.ofType(RouterActionTypes.BACK).pipe(tap(() => this.location.back()));

    @Effect({ dispatch: false })
    navigateForward$ = this.actions$.ofType(RouterActionTypes.FORWARD).pipe(tap(() => this.location.forward()));
}

Page Title

Example of ngrx page title:

@Injectable()
export class TitleEffects {
    constructor(private actions$: Actions, private titleService: Title) {}

    @Effect({ dispatch: false })
    navigate$ = this.actions$
        .ofType(TitleActionTypes.SET)
        .pipe(
            map((action: SetTitle) => action.payload),
            tap((name: string) => this.titleService.setTitle(`${name} | Pizza Store`))
        );
}

Action Composition

You could combine Actions if you extend them (ya ya), for example:

class BuyPizza implements Action {}
class BuyPeporniPizza extends BuyPizza {}

In this scenario, you could make BuyPizza be trigger when you dispatch BuyPeporniPizza too.

Other Works of Art

export class CookPizza implements Action {
constructor(public payload: Pizza) {}
}
export interface PizzaState {
pizzas: any[];
cooking: boolean;
}
@Store<PizzaState>({
pizzas: [],
cooking: false
})
export class PizzaReducer implements Reducer {
// not sure if i like this or not...
readonly state: PizzaState;
@Action(CookPizza)
cookPizza(payload: Pizza): PizzaState {
return { ...this.state, cooking: true };
}
}
@Component({ ... })
export class MyComponent {
// member decorator
@Select(state => state.cooking)
cooking: Observable<boolean>;
constructor(
// constructor decorator
@Select(state => state.cooking) private cooking: Observable<boolean>,
private store: Store<PizzaState>) {}
click(pizza) {
this.store.dispatch(new CookPizza(pizza));
}
}
@cport1
Copy link

cport1 commented Dec 15, 2017

NGRX Example of an Effect calling other Effects

  @Effect()
  sessionTimerReset$ = this.actions$
    .ofType('[USER] SESSION_RESET')
    .switchMap(() => {
      this.authService.refreshToken();
      return Observable.of({type: '[USER] REFRESH TOKEN'});
    })
    .switchMap(() =>
      Observable.timer(environment.userTimeout)
        .switchMap(() => {
            this.userService.sessionWarning();
            return Observable.of({type: '[USER] SESSION_WARNING'});
          }
        )
      .concat(
      Observable.timer(1000 * 15)
        .switchMap(() => {
            return Observable.of({type: '[USER] SESSION_TIMEOUT'});
          }
        )
      ))
    .catch(this.handleError);
  @Effect()
  sessionTimeOut$: Observable<Action> = this.actions$
    .ofType('[USER] SESSION_TIMEOUT')
    .do(() => window.location.assign('/login'))
    .distinct();

@amcdnl
Copy link
Author

amcdnl commented Dec 21, 2017

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment