Angular 2 i Redux

Otóż okazuje się, że Redux świetnie współgra z aplikacjami w Angular 2 i biblioteka ta jest bardzo często wykorzystywana razem z tym frameworkiem. Poprzednio opisywałem sposoby na komunikację pomiędzy komponentami sugerowane przez twórców Angulara, wszystkie miały jednak pewną wadę: Dobrze działały tylko w przypadku prostych scenariuszy. Użycie Reduksa znacznie upraszcza zarządzanie stanem nawet najbardziej złożonych aplikacji oraz umożliwia łatwą komunikację pomiędzy komponentami. Na czym dokładnie polega filozofia Reduksa opisywałem we wpisie Flux i Redux, więc osoby nieznające tego konceptu zapraszam do tamtego wpisu. W tym artykule chciałbym skupić się na bardzo konkretnym przykładzie użycia biblioteki Redux razem z Angular 2.

Sposobów połączenia Reduksa z Angularem 2 jest wiele, między innymi biblioteka ng2-redux, czy bardziej skomplikowany koncept łączący FRP i Reduksa: ngrx/store. Tutaj prezentuję najprostszą metodę, dzięki czemu wiedza ta jest najbardziej uniwersalna.

Koncepcja

Napiszmy teraz znowu listę zadań w Angular 2, tym razem wykorzystując Reduksa do zarządzania stanem aplikacji. Zacznijmy od zaprojektowania kodu i podziału na komponenty. Potrzebujemy komponent do dodawania zadań, komponent reprezentujący ich listę oraz komponent będący pojedynczym zadaniem na liście. Koncepcyjnie HTML będzie wyglądać tak:

<my-app>  
    <my-add-todo></my-add-todo>
    <my-todo-list>
        <my-todo-list-item></my-todo-list-item>
        <my-todo-list-item></my-todo-list-item>
        …
    </my-todo-list>
</my-app>  

Redux

Akcje

Rozpoczynamy od stworzenia projektu oraz definiujemy akcje Reduksa w pliku todoActions.ts. W tej prostej appce potrzebujemy tylko dwóch akcji:

  • utworzenie zadania
  • oznaczenie zadania jako ukończone/nieukończone

Oprócz tego, w tym samym pliku umieszczamy też klasę z metodami ułatwiającymi tworzenie akcji (tzw. action creators):

export const ADD_TODO = 'ADD_TODO';  
export const TOGGLE_TODO = 'TOGGLE_TODO';

@Injectable()
export class TodoActions {  
  private nextTodoID = 0;

  addTodo(title:string) {
    return {
      id: this.getNextID(),
      type: ADD_TODO,
      title,
      complete: false
    };
  }

  toggleTodo(id:number) {
    return {
      id,
      type: TOGGLE_TODO
    };
  }

  private getNextID() {
    return ++this.nextTodoID;
  }
}

Klasa oznaczona jest dekoratorem @Injectable, aby było możliwe jej wstrzyknięcie do klas komponentów poprzez Dependency Injection. Implementacja getNextID może być dowolna, tutaj dla prostoty id zadań to kolejne liczby naturalne. Jednocześnie w pliku todo.ts definiujemy sobie pomocniczą klasę oznaczającą nowe zadanie na liście:

export class Todo {  
  id:number;
  title:string;
  complete:boolean;
}

Reducer

Następnym krokiem pracy z reduksem jest stworzenie tzw. reducera. W tym przypadku reducer jest tylko jeden, bo aplikacja jest niezwykle prosta. Zaczynamy do zadeklarowania interfejsu dla stanu naszej aplikacji oraz zdefinowania stanu początkowego:

interface AppState {  
  todos: Array<Todo>;
}

const initialState:AppState = {  
  todos: []
};

Nasz reducer działa w ten sposób, że sprawdza którą z akcji ma obsłużyć i wywołuje odpowiednią funkcję:

export function rootReducer(state:AppState = initialState, action):AppState {  
  switch (action.type) {
    case ADD_TODO:
      return addTodo(state, action);
      break;
    case TOGGLE_TODO:
      return toggleTodo(state, action);
      break;
    default:
      return state;
  }
}

function addTodo(state:AppState, action):AppState {  
  return {
    todos: [
    ...state.todos,
    {id: action.id, title: action.title, complete: action.complete}
    ]
  };
}

function toggleTodo(state:AppState, action):AppState {  
  return {
    todos: state.todos.map(todo => {
      if (todo.id === action.id) {
        return {
          id: todo.id,
          complete: !todo.complete,
          title: todo.title
        };
      }
      return todo;
    })
  };
}

Reducer nie mutuje todos, zawsze zwraca nową tablicę.

Store

Na podstawie tak napisanego reducera tworzymy store, a następnie informujemy Angulara, że ten store jest dostępny jako zależność do wstrzyknięcia. Dodatkowo wywołujemy tutaj funkcję window.devToolsExtension() – jest to funkcja udostępniania przez wtyczkę Redux DevTools do przeglądarki Google Chrome. Wtyczka ta znacznie ułatwia pracę z Reduksem, pozwala na przykład przejrzeć wszystkie zdarzenia, które miały miejsce w aplikacji, a także dowolnie je cofać i powtarzać. Mała próbka możliwości:

Cały kod umieszczamy w pliku main.ts, w którym zwyczajowo znajduje się wywołanie funkcji bootstrap. Drugim, do tej pory pomijanym argumentem funkcji bootstrap jest tablica providers. Jej działanie jest analogiczne do własności o tej samej nazwie w komponentach.

const appStoreFactory = () => {  
  const appStore = createStore(rootReducer, undefined, window.devToolsExtension && window.devToolsExtension());
  return appStore;
};

bootstrap(AppComponent, [  
  provide('AppStore', { useFactory: appStoreFactory }),
  TodoActions 
]);

Warto zwrócić uwagę na nietypowy sposób w jaki użyty jest tutaj appStore – na potrzeby tego wpisu nie będę się zagłębiał w ten temat (ale na pewno samemu Dependency Injection w Angular 2 poświęce cały osobny artykuł!). Należy jedynie pamiętać, że w Angularze wstrzykiwać możemy albo klasy, albo dowolne wartości identyfikowane po nazwie. Tutaj appStore nie jest klasą, więc będzie identyfikowany pod nazwą AppStore.

Komponenty

Kolejnym krokiem jest stworzenie komponentów AddTodoComponent, TodoListComponentTodoListItemComponent. Możemy do tego wykorzystać Angular CLI, jak już to omawiałem w jednym z poprzednich wpisów. Modyfikujemy AppComponent dodając do niego tablicę providers z wymienionymi komponentami oraz używamy ich w szablonie:

@Component({
  selector: 'my-app',
  directives: [AddTodoComponent, TodoListComponent],
  template: `
  <h1>To do list with Redux</h1>
  <my-add-todo></my-add-todo>
  <my-todo-list></my-todo-list>
  `
})
export class AppComponent {  
}

AddTodoComponent

Pierwszy z komponentów jest odpowiedzialny za dodawanie elementów do listy. Nic prostszego, zwykły input, ngModel oraz metoda, która tworzy akcję ADD_TODO:

@Component({
    selector: 'my-add-todo',
    template: `
    <form>
      <label>
        Nowe zadanie:
        <input type="text" [(ngModel)]="newTodoTitle" (keyup.enter)="addTodo()">
      </label>
    </form>
    `
})
export class AddTodoComponent {  
    newTodoTitle:string;

    addTodo() {
      if (!this.newTodoTitle) {
          return;
      }
      const action = this.todoActions.addTodo(this.newTodoTitle);
      this.appStore.dispatch(action);
      this.newTodoTitle = '';
    }

    constructor(@Inject('AppStore') private appStore:Store,
                private todoActions:TodoActions) {
    }
}

Zwróćmy uwagę w jaki sposób wstrzykiwany jest appStore – identyfikowany jest po nazwie i używamy do tego dekoratora @Inject('AppStore').

TodoListComponent

Drugi komponent naszej aplikacji to lista. Jej zadaniem będzie pobranie tablicy zadań oraz reagowanie na zmiany. Obie te rzeczy realizujemy poprzez wywołanie funkcji appStore.subscribe() w momencie inicjalizacji komponentu. Ważne jest jednak, aby tę subskrypcję usunąć gdy komponent jest niszczony. Posłużą do tego zdarzenia cyklu życia. Gdy już mamy tablicę zadań, wyświetlimy je w szablonie przy pomocy *ngFor i komponentu TodoListItemComponent, do którego przekażemy obiekty z zadaniami:

@Component({
  selector: 'my-todo-list',
  directives: [TodoListItemComponent],
  template: `
  <my-todo-list-item *ngFor="let todo of todos" [todo]="todo"></my-todo-list-item>
  `
})
export class TodoListComponent implements OnInit, OnDestroy {  
  todos:Array<Todo>;

  private unsubscribe:Function;

  ngOnInit() {
    this.unsubscribe = this.appStore.subscribe(() => {
      const state = this.appStore.getState();
      this.todos = state.todos;
    });
  }

  ngOnDestroy() {
    this.unsubscribe();
  }

  constructor(@Inject('AppStore') private appStore:Store) {
  }
}

TodoListItemComponent

To już chyba tylko formalność. Ten komponent reprezentuje element na liście zadań i ma jeden atrybut wejściowy @Input() todo, a po kliknięciu na checkbox wysyłana jest odpowiednia akcja:

@Component({
  selector: 'my-todo-list-item',
  template: `
  <label>
    <input type="checkbox" (change)="onTodoClick()" [checked]="todo.complete">
    {{ todo.title }}
  </label>
  `
})
export class TodoListItemComponent {  
  @Input() todo:Todo;

  onTodoClick() {
    const action = this.todoActions.toggleTodo(this.todo.id);
    this.appStore.dispatch(action);
  }

  constructor(@Inject('AppStore') private appStore:Store,
              private todoActions:TodoActions) {
  }
}

Demo

Aplikacja jest gotowa. Zaimplementowaliśmy bardzo prostą listę zadań z użyciem frameworka Angular 2 oraz biblioteki Redux. Efekt jest widoczny poniżej. Tak prosty przykład być może nie oddaje jeszcze pełni zalet wynikających z wykorzystania Reduksa, jednak gdy aplikacja będzie rosła na pewno docenimy możliwości, które daje Redux oraz łatwość z jaką możemy rozwijać nasze komponenty bez konieczności czynienia drastycznych zmian w innych częściach aplikacji. Zachęcam do komentowania 🙂

  • Kuba

    Takie pytanie luźniej związane z artykułem: co sądzisz o przyszłości Angulara? Przyszłościowa technologia czy jednak zabawka? Aktualnie poświęcam czas na nauce Angulara 2 i NodeJS i zastanawiam się czy to dobry wybór i jak będzie z pracą w tej dziedzinie.

    • Jeśli chodzi o AngularJS (1.x) to jestem pewien, że każdy kto zna tę technologię bez problemu znajdzie pracę jeszcze przez wiele lat. W Angularze powstało multum różnych aplikacji, które nigdy nie zostaną przepisane na Angular 2 i z tego powodu będą potrzebne ręcę do pracy przy utrzymywaniu tych appek przy życiu 🙂

      Natomiast odnośnie Angular 2 moje zdanie jest takie, że nie osiągnie aż takiej popularności jak AngularJS 1. Dlaczego? Widzę dwa powody:
      – Po pierwsze nasza społeczność zaczęła bardzo ostrożnie podchodzić do nowych frameworków od momentu gdy ogłoszono zaprzestanie rozwoju AngularJS 1.
      – Po drugie tzw. target Angulara 2 jest zupełnie inny niż AngularJS 1. Budowanie aplikacji w Angular 2 wymaga znacznie więcej pracy, wiedzy i przygotowań, a jednocześnie są dostępne fajnie, lekkie i proste alternatywy – np. React. Z tego powodu wydaje mi się, że Angular 2 sprawdzi się głównie w naprawdę dużych, rozbudowanych projektach.

      To czego na pewno warto się uczyć to wzorce projektowe, które są uniwersalne i nieśmiertelne niezależnie od frameworka czy nawet języka programowania. Angular 2 garściami czerpie z dobrych wzorców projektowych 🙂

      • Kuba

        Coś w tym jest, Angular 1.x cieszy się właśnie dużo większą popularnością. Ale zobaczymy jak to będzie gdy wyjdzie w pełni kompletna „dwójka”. Polecasz jakąś dobrą książkę na temat wzorców projektowych?

      • Adam Kowal

        Przede wszystkim chciałbym podziękować za Twoją pracę, która wkładasz by dzielić się wiedzą. Bardzo to doceniamy.

        Mam pytanie odnośnie frameworków. Znam dość dobrze JS ale nie znam żadnego z frameworków. Moim celem jest tworzenie aplikacji webowych. Uważam że na naukę Angular 1 już za późno. Myślałem o Angular 2 lub React. W jakim frameworku Twiim zdaniem tworzyć aplikacje żeby nabrać umiejętności najbardziej przydatnych?

        • Najlepiej poznawać wzorce projektowe i uczyć się pisać dobry, czytelny kod bez frameworków.
          Możesz spróbować skorzystać z bibliotek takich jak vue.js czy React, żeby było trochę łatwiej. React jest szczególnie na topie teraz.

          • Adam Kowal

            Bardzo dziękuję za odpowiedź!

    • Ponoć Angular1 świetnie nadaje się do małych i nisko-średnich aplikacji. Angular2 do średnich i dużych aplikacji. Tak mówią. Miejsce pewnie będzie dla obu technologii.

      • Też słyszałem takie opinie, ale z tego powodu próg wejścia w Angular 2 jest jeszcze wyższy, a pole zastosowań mniejsze i na pewno nie stanie się aż takim hitem jak AngularJS.

  • Grzegorz Lipecki

    Jak w tego typu aplikacji (ng2+redux) zarządzać nawigacją?

    Jak rozumiem, nawigacja pomiędzy poszczególnymi ekranami aplikacji powinna być odzwierciedlona w stanie aplikacji i realizowana zdarzeniami z redukcjami. Jak w takim przypadku możemy to połączyć z routerem i odzwierciedleniem bieżącej ścieżki w url oraz w drugą stronę, jak odzwierciedlić zmianę url w stanie?

    Jak już jesteśmy przy url’u, dobrą praktyką byłoby odzwierciedlenie np. filtra na liście w parametrze url, żeby umożliwić użytkownikowi nawigację w przód/wstecz, czy też zapisanie linka do późniejszego powrotu. Czy w takim przypadku będziemy musieli utrzymać stan w obu miejscach (url vs stan aplikacji) i narażać się na problemy związane z synchronizacją tych wartości?

    Jak wyglądałoby podejście z wykorzystaniem ng2+redux? Czy możesz wskazać przykładowy, nietrywialny projekt realizując nawigację w aplikacji?

    • Nawigacja wcale niekoniecznie musi być odzwierciedlona w Reduksie. I w większości znanych mi przypadków rzeczywiście nie jest. Domyślnie Redux + React + React Router tego nie robią, podobnie domyślnie Redux + Angular 2 + Angular Router tego nie robią.

      Ale jest to możliwe. Nie testowałem tego jednak i nie umiem Ci odpowiedzieć czy to się sprawdza, albo jak rozwiązano problem z URL-ami. Był taki projekt jak ngrx/router, ale został porzucony na rzecz oficjalnego Angular Router.

      • Grzegorz Lipecki

        Dzięki! 🙂