1.1.0 • Published 4 years ago

dd-rx-state v1.1.0

Weekly downloads
8
License
MIT
Repository
github
Last release
4 years ago

dd-rx-state

Redux-like state handling, but created with rxjs and allowing for less but typesafe boilerplate with typescript (Source Code).

Info

Note: for Angular developers check out quickstart section further below.

Same as with Redux the global state needs to be identified and then built using actions (with unique names i.e. types) and state reducers (functions which take the current state and an action and return either the same state if nothing changed or the mutated copy). The difference is that the state assembling results in an Observable stream of the state and that for static type safety the use of Actor is encouraged over the use of Action (though it is also possible to dispatch the actions directly in the store).

Example

Task: a part of an UI client needs to filter, sort and show e.g. names of products.

Identify State

Following parts are needed:

  • the products are shown as list of names
  • the list can be sorted
  • the list can be filtered by:
    • a list of brands (or all if empty)
    • a string filter for the name (or any if empty)
    • a map of arbitrary key-value tags (or any if empty)
interface ProductsFilter {
  brands?: string[];
  nameFilter?: string;
  tags?: {[key: string]: string};
}

export interface StateViewProducts {
  filter?: ProductsFilter;
  products?: string[];
  sortAsc?: boolean;
}

Identify Actions

Identifying the actions/actors is pretty easy now that the state is clear.

Keep in mind that the Action.type must be unique for all actions (same as in Redux), so let's create an interfix to be used in the current state's actions to prevent accidental repeating of types.

import {STATETAG as PARENT_STATETAG} from './state';

// ... state interfaces ...

// PARENT_STATETAG could be e.g. 'UI'
const STATETAG = PARENT_STATETAG + '_PRODUCTS';

// now let's define the actors for ProductsFilter
export const reset_filter = actor<ProductsFilter>('RESET', STATETAG, 'filter');
export const set_filter_brands = actor<string[]>('SET', STATETAG, 'filter', 'brands');
export const set_filter_nameFilter = actor<string>('SET', STATETAG, 'filter', 'nameFilter');
export const set_filter_tags = actor<{[key: string]: string}>('SET', STATETAG, 'filter', 'tags');

// and actors for StateViewProducts
export const set_products = actor<string[]>('SET', STATETAG, 'products');
export const set_sortAsc = actor<boolean>('SET', STATETAG, 'sortAsc');

Notice how the StateViewProducts.filter property does not have an actor - this state will be completely assembled from the existing ProductsFilter actors.

Create State

Using the interfaces and actors the states now can be created and assembled accordingly.

// ... state interfaces ...
// ... actors ...

export const DEFAULT_FILTER = <ProductsFilter>{brands: [], nameFilter: null, tags: {}};

const state_filter$ = initReduceAssemble$_(DEFAULT_FILTER, {
  [reset_filter.type]: redSet,
  [set_filter_brands.type]: redSetPropertyIfNotEqual_('brands'),
  [set_filter_nameFilter.type]: redSetPropertyIfNotSame_('nameFilter'),
  [set_filter_tags.type]: redSetPropertyIfNotEqual_('tags'),
});

export const state_view_products$ = initReduceAssemble$_(
  <StateViewProducts>{filter: null, products: [], sortAsc: true},
  {
    [set_products.type]: redSetPropertyIfNotEqual_('products'),
    [set_sortAsc.type]: redSetPropertyIfNotSame_('sortAsc'),
  },
  {filter: state_filter$},
);

And that's it - there is no more boilerplate code needed than that. Furthermore most parts are typesafe i.e. redSetPropertyIfNotSame_ will check for the field name to actually be a key of the state type.

The assembled state_view_products$ must now be assembled in the whatever parent state, for example:

import {state_view_products$} from './state-products';

// top state
export interface UiState {
  viewProducts?: StateViewProducts;
}

export const state_ui$ = initReduceAssemble$_(
  <UiState>{viewProducts: null},
  null, // no own reducers
  {viewProducts: state_view_products$},
);

Define RxState

Let's assume that UiState is the top state and we create the RxState correspondingly:

export class RxStateUi extends RxState<UiState> {
  constructor(store: Store<State>) { super(store); }
}

...

// alternatively all of this is done via e.g. Angular injections
const rxState = new RxStateUi(createStore(state_ui$));

Watch State

Now two components are to be created: ProductsFilterComponent and ViewProductsComponent.

Note: following snippets are just samples due to unknown frontend framework.

ProductsFilterComponent

This component presents and mutates the filter values and sorting.

export class ProductsFilterComponent {
  constructor(private readonly rxState: RxStateUi) {}

  // state observables - e.g. use with async in Angular HTML templates

  readonly brands$ = this.rxState.pipe(watch((state) => state.viewProducts.filter.brands));
  readonly nameFilter$ = this.rxState.pipe(watch((state) => state.viewProducts.filter.nameFilter));
  readonly tags$ = this.rxState.pipe(watch((state) => state.viewProducts.filter.tags));
  readonly sortAsc$ = this.rxState.pipe(watch((state) => state.viewProducts.sortAsc));

  // state mutators - called from HTML template

  setBrands = this.rxState.act_(set_filter_brands, orArray);
  setName = this.rxState.act_(set_filter_nameFilter, orNull);
  setTags = this.rxState.act_(set_filter_tags, orObject);
  setSortAsc = this.rxState.act_(set_sortAsc, forceBool);
  resetFilter = () => this.rxState.act(reset_filter, DEFAULT_FILTER);
}

Note: in e.g. VS Code all the observables and setters show and expect the correct types.

ViewProductsComponent

This component shows the products and reloads the result according to filter values and sorting.

export class ViewProductsComponent {
  constructor(private readonly rxState: RxStateUi, private readonly api: ProductsApi) {}

  private readonly done$ = new Subject();

  // state observables for reacting to

  private readonly filter$ = this.rxState.pipe(watch((state) => state.viewProducts.filter));
  private readonly sortAsc$ = this.rxState.pipe(watch((state) => state.viewProducts.sortAsc));

  // state observables - e.g. use with async in Angular HTML templates

  readonly products$ = this.rxState.pipe(watch((state) => state.viewProducts.products));

  // state mutators

  private setProducts = this.rxState.act_(set_products, orArray);

  init() {
    combineLatest(this.filter$, this.sortAsc$)
      .pipe(
        debounceTime(0),
        switchMap(([filter, sortAsc]) => this.api.httpGetProducts$(filter, sortAsc)),
        takeUntil(this.done$),
      )
      .subscribe(this.setProducts);
  }

  destroy() {
    this.done$.next();
    this.done$.complete();
  }
}

For not-rxjs-savvy developers; the init() function:

  • watches the latest values from filter and sorting with combineLatest
  • waits for a frame after any change with debounceTime to throttle rapid filter changes
    • i.e. to not request and cancel the api requests unnecessarily
  • goes over to requesting the actual products via some api with switchMap
  • registers cancel of this stream when done$ is fired with takeUntil
    • i.e. to cancel any requests still running when component gets destroyed
  • mutates the state by setting the received products
    • which will be reflected in the products$ Observable immediately afterwards
      • which makes it very easy to separate the view part from the requesting part if desired

Result

  • Using dd-rx-state the state boilerplate can be held at a minimum while still providing type safety in most parts
  • Using the reactive nature of the state observers the state watching can be done in just a few lines without resorting to callbacks
  • Using actors it is easy to create type safe mutator functions
  • Any UI components can now be destroyed and re-created and will always automatically show the last state (as it is now the only state)

Angular Quickstart

With typescript being the standard for Angular applications the dd-rx-state dependency can help to replace Redux in a more typescript friendly and typesafe way.

Inject State

The process to initialize the state is kept in line with the Redux approach:

// Create/Assemble some state:
export interface State { ... }
export const state$ = initReduceAssemble$_(
  <State>{ ... }, // init
  { ... }, // reducers
  { ... }); // nested states

// Create the injector and factory:
export const AppRxStore = new InjectionToken('App.store.rx');
export function createAppRxStore() {
  return createStore(state$);
}

// Add the factory to app.module.ts:
providers: [
  ...
  { provide: AppRxStore, useFactory: createAppRxStore },
  ...
]

Tests

Don't forget to add the injector tokens in unit tests too.

describe('test', () => {
  beforeEach(() => {
    TestBed.configureTestingModule({
      providers: [{provide: AppRxStore, useFactory: createAppRxStore}],
    });
  });
});

Create Service

Just wrap RxState in an Angular service.

@Injectable({providedIn: 'root'})
export class RxStateService extends RxState<State> implements OnDestroy {
  constructor(@Inject(AppRxStore) protected readonly store: Store<State>) {
    super(store);
  }

  ngOnDestroy() {
    this.destroy();
  }
}

HowTo: Watch State

@Component({
  selector: 'app-user-details',
  templateUrl: './user-details.component.html',
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class UserDetailsComponent implements OnDestroy {
  constructor(private readonly rxState: RxStateService) {}

  readonly access$ = this.rxState.pipe(watch((state) => state.userInfo.access.current));
  readonly groups$ = this.rxState.pipe(watch((state) => state.userInfo.oauth.groups));
  readonly name$ = this.rxState.pipe(watch((state) => state.userInfo.oauth.name));
  readonly accessGeneralDenied$ = this.rxState.pipe(watch((state) => state.userInfo.accessGeneral <= EAccess.Unset));
  readonly accessGeneralGroupsNeeded$ = combineLatest([
    this.rxState.pipe(watch((state) => state.userInfo.generalOauthGroupsRead)),
    this.rxState.pipe(watch((state) => state.userInfo.generalOauthGroupsWrite)),
  ]).pipe(map(([reads, writes]) => [...reads, ...writes]));
}

HowTo: Separate Setters from RxStateService

When the state grows it could be a good idea to separate the state flow process into the RxStateService being directly used for watching only and creating multiple setter services for state mutating.

// Create state from reducers and actors

export class OAuthData {
  active: boolean;
}

export const set_oauth_name = actor<string>('SET_USER_oauth_active');

export const state_oauth$ = initReduceAssemble$_(<OAuthData>{active: false}, {[set_oauth_active.type]: redSetPropertyIfNotSame_('active')});

// Create a service just for mutating the UserInfo state

@Injectable({providedIn: 'root'})
export class RxStateSetUserInfoService {
  constructor(private readonly rxState: RxStateService) {}

  setOauthActive = this.rxState.act_(set_oauth_active, forceBool);
}

// Inject the mutator wherever UserInfo needs to be changed

export class UserComponent {
  constructor(private readonly rxMutateUserInfo: RxStateSetUserInfoService) {}
  logout = () => this.rxMutateUserInfo.setOauthActive(false);
}

License

MIT

1.1.0

4 years ago

1.0.6

4 years ago

1.0.5

4 years ago

1.0.4

4 years ago

1.0.3

4 years ago

1.0.2

4 years ago

1.0.1

5 years ago

1.0.0

5 years ago