JSPM

  • Created
  • Published
  • Downloads 6
  • Score
    100M100P100Q52024F
  • License MIT

RxJS and ImmutableJs powered nested state managment for Angular2 apps inspired by @ngrx.

Package Exports

  • ng-state

This package does not declare an exports field, so the exports above have been automatically detected and optimized by JSPM instead. If any package subpath is missing, it is recommended to post an issue to the original package (ng-state) to support the "exports" field. If that is not possible, create a JSPM override to customize the exports field for this package.

Readme

ng-state

RxJS and ImmutableJs powered nested state management for Angular 2 applications inspired by @ngrx/store.

npm version

Table of Contents

  1. Introduction
  2. Main differences
  3. Installation
  4. Examples
  5. Main idea
  6. Configuration
  7. ngOnChanges hook
  8. InjectStore decorator
  9. Wiring things together
  10. Subscribe stright to store
  11. When item details on different page
  12. Dispatcher
  13. Debuging
  14. IsProd
  15. Time travel
  16. Flow diagram
  17. Testing
  18. Contributing

Introduction

ng-state is a controlled nested state container designed to help write performant, consistent applications on top of Angular 2. Core tenets:

  • State is a single immutable data structure
  • Each component gets its own peace of nested state
  • State accessed with actions variable under component or the Store, an observable of state and an observer of global state

These core principles enable building components that can use the OnPush change detection strategy giving you intelligent, performant change detection throughout your application.

Main differences from other RxJs store based state managements solutions

  • Developers do not need to rememebr long nested paths to access store
  • Decoples / Hides paths to state from components
  • Uses Redux like pure functions - actions to interact with state
  • Less boilerplate

Installation

Install ng-state from npm:

npm install ng-state --save

Examples

Main idea

In order to work with peace of state, current state path (statePath) and current lits item index (stateIndex) is passed down to child components and are received in state actions. Or absolute pats are set in state actions. (see explanation image at the bottom)

Configuration

In your app's main module, register store with initial state by using StoreModule.provideStore(initialState) ( where initialState is simple object ) function to provide it to Angular's injector:

import { NgModule } from '@angular/core'
import { StoreModule } from 'ng-state';

@NgModule({
  imports: [
    BrowserModule,
    StoreModule.provideStore(initialState)
  ]
})
export class AppModule {}
let initialState = {
  todos: [],
  interpolationTest: 'initial'
};

export { initialState };

Then create actions for each component state by decorating class with @InjectStore decorator and HasStore inheritance. This action will receive only that peace of nested state wich is provided as first parameter.

import * as Immutable from 'immutable';
import { HasStore, InjectStore } from "../../react-state/decorators/inject-store.decorator";
import { Store } from "../../react-state/store/store";
import { TodoModel } from "./todo.model";

@InjectStore('todos')
export class TodosStateActions extends HasStore<Immutable<List<any>>> {
    addTodo(item: TodoModel) {
        this.store.update(state => {
            state.push(Immutable.fromJS(item));
        })
    }

    deleteTodo(index: number) {
        this.store.update(state => {
            state.delete(index);
        }, false);
    }

    get todos() {
        return this.store.map((state) => {
            return state.toArray();
        });
    }

    /// OR

    get todos() {
      return this.state.toArray();
    }
}

To reflect data in component retrieved stright from this.state you need to pass ChengeDetectorRef to HasStateActions class which is extended by components.

Be aware that from version 1.2.5 simple getters that returns Observable are converted to properties to get better performance by reducing calls to functions.

ngOnChanges hook

Starting from version 3.2.0 ngOnChanges is not called before actions not initialized (before ngOnInit). This behaviour can be disabled passing true as a second param to ComponentState decorator.

InjectStore decorator

first parameter is path

  • if added between single quotes '' it counts as absolute path
  • if added in array [], final path will be merrged with path passed from parent ([statePath]="statePath"):

['b'] -> ['a', 'b']

  • if state is part of the list, ${stateIndex} param should be passed from the parent component and new path will look like:

['b', '${stateIndex}'] -> ['a', 'b', 0]

  • stateIndex param can be used in absolute path as well: 'a/b/${statePath}' -> ['a', 'b', 0]
  • stateIndex can be an array of indexes so state path can have multiple ${stateIndex}: ['${stateIndex}', 'some_other_path', '${stateIndex}']
  • there can be usecases when actions can be shared because of identical states keeping in different locations. In this case there can be anonymus function passed as a first parameter:
@InjectStore((currentPath: string[]) => {
    return currentPath.indexOf('search') >= 0
        ? ['entities', '${stateIndex}']
        : ['${stateIndex}'];
})

second parameter is initial state:

this is optional parameter and can add default state for that path

export const FooInitialState = {
    loading: false,
    entities: [],
};

Wiring things together

Now you can inject state actions by marking component with @ComponentState decorator and inheriting from IComponentState interface. Notice that statePath and stateIndex parameters are passed from todos to todo-description in order to use relative path in todo-description state actions.

@ComponentState(TodosStateActions)
@Component({
  selector: 'todos',
  templateUrl: './todos.component.html',
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class TodosComponent extends HasStateActions<TodosStateActions> {
  constructor(cd: ChangeDetectorRef){
    super(cd)
  }
  // actions available here
}
<tr *ngFor="let todo of actions.todos | async; let i = index;">
  <th scope="row">{{ i + 1 }}</th>
  <td>{{ todo.name }}</td>
  <td>
    <todo-description [statePath]="statePath" [stateIndex]="i"></todo-description>
  </td>
  <td><button (click)="deleteItem(i)">X</button></td>
</tr>

statePath and stateIndex properties are created in decorator and injected into Angular component to avoid boilerplate @Input's.

@ComponentState may take state actions object or anonymous function to select an object for creating instance:

@ComponentState(TodosStateActions)

OR

@ComponentState((component: TodosComponent) => {
  return component.isFromCollection
    ? A_StateActions
    : B_StateActions;
})

You can also inject the Store service into your components and services. Use store.select to select slice(s) of state:

import { Store } from 'ng-state';

interface AppState {
  counter: number;
}

@Component({
  selector: 'my-app',
  template: `
    <div>todos: {{ (todos | async)?.getIn([0]) }}</div>
  `
})
class MyAppComponent {
  todos: Observable<number>;

  constructor(private store: Store<AppState>){
    this.todos = store.select(['todos']);
  }
}

Subscribe stright to store

also you can avoid having async pipe by subscribing to state change. But then you will be responsible for subscription management. Hence it is recommended to leave this for Angular.

@Component({
  selector: 'my-app',
  template: `
    <div>todos: {{ todos.getIn([0]) }}</div>
  `
})
class MyAppComponent {
  todos: Observable<number>;
  counterSubscription: Rx.Subscription;

  constructor(private store: Store<AppState>) implements OnDestroy {
    this.counterSubscription = store.select(['todos'])
      .subscribe(state => {
        this.todos = state;
      });
  }

  ngOnDestroy(){
    this.counterSubscription.unsubscribe();
  }
}

When item details on different page

There can be situation when list item is on page and its details on another. So question is how to deal with stateIndex. For this case you can pass list item index along with url params

<a href="#" [routerLink]="['/dictionaries', i]" class="card-link">Go To Values</a>

and on target component catch it and assign to stateIndex

constructor(private route: ActivatedRoute, private router: Router) {
    super();
    this.route.params.subscribe((params: Params) => {
      this.stateIndex = params.id;
    });
  }

and it will be passed to actions automatically.

Dispatcher

There are cases when states of not related components, which has different places in state tree, should change e.g: when list item is selected filter should collapse. This is where dispatcher kicks in. Dispatcher is design to send and receive messages between components.

/* Child A */
export class UpdateMessage extends Message {
  constructor(payload?: any) {
    super('MessageName', payload);
  }
}

dispatcher.subscribe(UpdateMessage, (payload: any) => {
  this.actions.update....
});

/* Child B */
dispatcher.publish(new UpdateMessage('payload'));

Or, by using overload, even more simpler

/* Child A */
dispatcher.subscribe('UPDATE_MESSAGE', (payload: any) => {
  this.actions.update....
});

/* Child B */
dispatcher.publish('UPDATE_MESSAGE', 'payload');

Debuging

It is easy to debug latest state changes. Just write in console window.state.startDebugging() and latest state will be printed in console each time it changes. Usually developers need to debug some deeply nested state and it is anoying to enter path each time. For this reason you can pass state path to window.state.startDebugging(['todos', 0]) and only changes of this peace will be reflected.

To stop debug mode simply call window.state.stopDebugging()

Another way to debug is to add third parameter true on you InjectStore decorator. Console will start to show component state that uses those actions.

Production

From version 2.6 boolean flag can be passed to StoreModule.forRoot method. When production is enabled:

  • All manipulations with state from window object are not allowed
  • State is disconnected from window object
  • Warnings are disabled

However for custom manipulations state and its manipulations can be accessed from injected StateHistory service.

Time travel

@ng-state allows you to time travel. To enable this you have to add StateHistoryComponent to your app file

<state-history></state-history>

and from console run window.state.showHistory(). While you in the time travel mode history is not collected. To exit mode run window.state.hideHistory() command from the console. You can also view current state in window.state.CURRENT_STATE and whole history in window.state.HISTORY. This allows you to debug or write your own time travel component if necessary.

History collecting can be disabled by passing false to StoreModule.provideStore second parameter. By default 100 history steps are stored in memory but it can be modified by passing third parameter to StoreModule.provideStore.

Flow diagram

flow

Testing

Unit testing is important part of every software. For this reason ng-state has simplified test bed setup. In order to setup unit test you need to make few simple actions

Tell ng-state that actions are going to run in testing mode:

beforeAll(() => {
    NgStateTestBed.setTestEnvironment();
});

actions can be tested by calling NgStateTestBed.createActions method. createActions has required param actions and two params with default values: initialState with value {} and statePath with value []. This means that for most of situations we can pass just actions type and test application in localized state. But for more complex scenarios we can pass initial state and path.

 it('should return actions', () => {
    const initialState = { todos: [] };
    initialState.todos.push({ description: 'test description' });

    const actions = NgStateTestBed.createActions<TestActions>(TestActions); // in this case actions will be created with state = {};
    // OR
    const actions = NgStateTestBed.createActions(TestActions, initialState, ['todos', 0]) as TestActions;
    expect(actions.todoDescription).toEqual('test description');
});

where

  • first param is initialState is object or class
  • second param is statePath to bind actions to
  • third param is actions class

In order to test components with actions you need to call NgStateTestBed.setActionsToComponent method with actions and instance of component. Same like in example above just add

component: TodoComponent;

beforeAll(() => {
    NgStateTestBed.setTestEnvironment();
});

beforeEach(() => {
    component = new TodoComponent();
});
...
actions = ...

NgStateTestBed.setActionsToComponent(actions, component);

expect(component.actions.todoDescription).toEqual('test description');

that simple :)

Contributing

Please read contributing guidelines here.