Skoro podstawy ReactJS mamy już w małym palcu, możemy ruszyć do przodu. W dzisiejszym wpisie przedstawię Ci, moim zdaniem, idealny podział odpowiedzialności komponentów ReactJS. Nie jest to nic nowego w świecie ReactJS… przedstawiony dziś podział został swego czasu opisany w tym artykule przez Dana Abramova, autora m.in. Reduxa (o Reduxie napiszę co nieco w najbliższym czasie).

Myślę, że nie ma co przedłużać tego wstępu… Przejdźmy do rzeczy!

Proponowany podział

Opisywane dziś podejście do dzielenia odpowiedzialności zostało wcześniej opisane przez wspomnianego Dana Abramova w jego artykule na blogu. Postaram się dzisiaj rozwinąć jak najdokładniej ten temat, ponieważ uważam, że dobry podział odpowiedzialności komponentów ReactJS jest bardzo ważny dla utrzymania kodu aplikacji w ryzach.

Samo podejście jest bardzo proste w swoich założeniach. Jednak, jak pokazuje moje doświadczenie z komponentami ReactJS, wcale nie jest tak łatwo przypilnować go w rzeczywistym projekcie… Ogólnie rzecz biorąc chodzi o to, aby wszystkie komponenty w projekcie podzielić na dwie kategorie.

Pierwsza kategoria to komponenty spełniające rolę kontenerów. Przemierzając zakamarki sieci Internet, możesz natknąć się również, na określenie “mądre komponenty” (ang. smart components). Jest też kilka innych określeń ale są one raczej rzadko spotykane. Komponenty takie mają zwykle szeroki zakres odpowiedzialności ale o tym w dalszej części tego wpisu.

Druga kategoria to komponenty prezentacyjne określane też często “głupimi” (ang. dumb components). Ich główną i w zasadzie jedyną odpowiedzialnością jest, jak sama nazwa wskazuje, prezentacja danych użytkownikowi.

Przyjrzyjmy się teraz bliżej poszczególnym typom komponentów.

Komponenty - kontenery

Podział odpowiedzialności komponentów ReactJS rozpocznę od omówienia komponentów - kontenerów. Ogólnie rzecz biorąc odpowiadają one za to jak coś działa.

Komponenty takie mogą posiadać swój wewnętrzny stan. Zwykle też mogą być traktowane jako źródło danych. To znaczy, że odpowiadają one za wywołania serwisów komunikujących się z warstwą dostępu do danych, czyli na przykład z REST API. Pobrane przez siebie dane przekazują one następnie do komponentów prezentacyjnych.

Oprócz przekazywania danych, mogą one zawierać metody zapewniające obsługę zdarzeń. Referencje do takich metod przekazywane są do komponentów prezentacyjnych, gdzie podpinane są one do odpowiednich zdarzeń. Pokażę to zresztą za chwilę na przykładach.

Co istotne, komponenty - kontenery w metodzie render zawierają jedynie odwołania do komponentów prezentacyjnych lub też innych komponentów - kontenerów. Jeśli trafi się jakiś zwykły znacznik DOM to służy on zwykle tylko do “owinięcia” tych komponentów. W końcu, jak mogliście przeczytać w moim ostatnim wpisie na temat podstaw ReactJS, metoda render może zwracać tylko jeden główny znacznik. Jeżeli więc komponent - kontener pracuje na większej liczbie komponentów, to trzeba je owinąć jakimś divem lub spanem.

Z powyższego wynika jeszcze jedna cecha komponentu - kontenera. Nie zawiera on żadnych styli “inline” ani też nie importuje żadnych zewnętrznych arkuszy styli. To już jest odpowiedzialność komponentu prezentacyjnego…

W rzeczywistych aplikacjach zwykle jest tak, że pojedynczy kontener jest powiązany z pojedynczą stroną routingu. Na temat routingu będę jeszcze pisać na blogu, teraz wystarczy Ci tylko wiedzieć, że chodzi o to, że kontener taki można traktować jako główny komponent danej podstrony. Oczywiście jeśli pracujesz nad rozbudowanym projektem to nic nie stoi na przeszkodzie abyś podstrony podzielił również na obszary, za które odpowiadają osobne kontenery. Wtedy to, kontener główny renderuje kontenery dzieci, a one dopiero renderują komponenty prezentacyjne.

Przykład komponentu - kontenera

No dobra, tyle teorii… Na pewno nie możesz się doczekać przykładu prawda? Mówisz - masz! Poniżej dość prosty przykład komponentu - kontenera:

import React from 'react';
import fetch from 'isomorphic-fetch';

import TodoList from './TodoListComponent';
import SortButton from './SortButtonComponent';

class TodoContainer extends React.Component {
  constructor(props) {
    super(props);
    this.state = { todoList: [] };
  }

  componentDidMount() {
    fetch('//todo-api/get')
      .then(response => {
        if (response.status >= 400) {
          throw new Error("Bad response from server");
        }
        return response.json();
      })
      .then(todos => {

        this.setState({ todoList: todos })
      })
  }

  onButtonClick() {
    // sort this.state.todoList
  }

  render () {
    return (
      <div>
        <TodoList items={this.state.todoList} />
        <SortButton onButtonClick={this.onButtonClick.bind(this)} />
      </div>
    );
  }
}

export default TodoContainer;

Objaśnienie przykładu

Na pierwszy rzut oka przykład ten wydaje się trochę rozbudowany… Ale tak na prawdę nie ma tutaj nic skomplikowanego. Jak widzisz, na początku tego pliku, importujemy dwa komponenty prezentacyjne: TodoList oraz SortButton. Użyjemy ich za chwilę w metodzie render.

Jeśli spojrzysz teraz na konstruktor klasy TodoContainer zobaczysz, że definiujemy w nim stan początkowy komponentu. Zawiera on właściwość todoList zawierającą elementy listy “TODO”. Oczywiście na początku jest ona pusta, musimy więc ją wypełnić…

Dzieje się to w metodzie componentDidMount. Metoda ta jest jedną z metod obsługujących cykl życia aplikacji ReactJS. Ta konkretna metoda uruchamiana jest jako ostatnia, kiedy cały komponent jest już wyrenderowany. Na razie nie zaprzątajmy sobie więcej głowy cyklem życia Reacta - to temat na osobnego posta. Na tę chwilę wystarczy nam, że jest to właściwe miejsce aby wykonać “strzał” do serwisu w celu pobrania danych.

W przykładzie, do wykonania tego “strzału”, użyłem modułu isomorphic-fetch. Oczywiście możesz użyć dowolnego innego sposobu na pobranie danych. W przykładzie ważne jest, że wykonujemy to właśnie w komponencie - kontenerze.

Przejdźmy teraz do metody render. Jak widzisz zwraca ona element div. Jest on konieczny ponieważ zawiera on w sobie dwa komponenty. Jeśli byśmy ich nie “owinęli” w tego diva dostalibyśmy błąd. To co jest tutaj ważne: po pierwsze do komponentu TodoList przekazujemy pobrane elementy listy celem ich wyświetlenia; po drugie, do komponentu SortButton przekazujemy metodę obsługi przycisku “Sort” (jego implementacja nie jest teraz istotna więc ją pominąłem).

Przejdźmy więc do meritum:

  • Oba komponenty dzieci są komponentami prezentacyjnymi!
  • Pierwszy z nich wie tylko jak ma wyświetlić listę “TODO” - nie wie natomiast nic o elementach, które dostaje z zewnątrz.
  • Drugi z komponentów odpowiada tylko za wyświetlenie guzika “Sort” - nie wie zupełnie jak ma go obsłużyć.
  • O elementach do wyświetlenia wie natomiast kontener TodoContainer, podobnie jak wie on o tym jak obsłużyć guzik “Sort”.

Czyli reasumując: kontener wie jak pobrać dane oraz jak obsłużyć zdarzenia. Przekazuje on te informacje do komponentów prezentacyjnych w celu ich wyświetlenia/podpięcia.

Komponenty prezentacyjne

No dobrze, wiemy już czym jest kontener. Drugi element podziału odpowiedzialności komponentów ReactJS to wspomniany już komponent prezentacyjny. Trochę już o takich komponentach wspomniałem omawiając kontenery. Myślę jednak, że warto przyjrzeć się im bliżej.

Wiemy już, że komponenty prezentacyjne odpowiadają za to jak coś wygląda. Zawiera więc on informacje o stylach czy to “inline” czy importując odpowiednie, zewnętrzne arkusze. Jego JSX jest zwykle bardziej rozbudowany: zawiera wszystkie niezbędne dla prezentacji tagi.

Oprócz standardowych tagów DOM może on, w metodzie render, zawierać odwołania do innych komponentów prezentacyjnych ale też innych kontenerów. Sam jednak nie zawiera żadnej logiki - ogranicza się więc tylko do przekazania odpowiednich parametrów od rodzica do swoich dzieci.

Skoro nie zawiera żadnej logiki to nie powinien zawierać też żadnych referencji do serwisów itp. Importuje on tylko moduł react, ewentualnie arkusze styli oraz inne komponenty, które staną się jego “dziećmi”.

Nie zmienia on też w żaden sposób otrzymanych danych ani nie posiada on zwykle własnego, wewnętrznego stanu (ale nie jest to regułą).

Przykład(y) komponentu prezentacyjnego

Dla dopełnienia obrazu całości, poniżej przedstawiam przykłady obu wspomnianych wcześniej komponentów prezentacyjnych. Na początek komponent TodoList:

import React from 'react';

class TodoList extends React.Component {
  render () {
    return (
      <ul>
        {this.props.items.map(item => {
          return <li key={item.id}>{item.name}</li>
        })}
      </ul>
    );
  }
}

export default TodoList;

Jak widzisz, jest to zwykły komponent ReactJS. Zawiera on tylko metodę render, ponieważ nie ma on swojego własnego stanu ani nie implementuje żadnej logiki.

Metoda render zwraca jedynie kod JSX odpowiedzialny za wyświetlenie listy. Wykorzystuje ona do tego elementy przekazane mu z komponentu - kontenera. Oczywiście są one dostępne w obiekcie this.props. Wydaje mi się, że powyższy przykład nie wymaga więcej wyjaśnień.

Drugim przykładem komponentu prezentacyjnego jest komponent SortButton. Oto jego przykładowa implementacja:

import React from 'react';

class SortButton extends React.Component {
  render () {
    return <button onClick={this.props.onButtonClick}>Sort</button>;
  }
}

export default SortButton;

Tutaj również nie mamy do czynienia z inżynierią kosmiczną… Komponent ten wyświetla jedynie guzik “Sort” oraz przypisuje mu metodę obsługującą zdarzenie click. Metodę tę pobiera on z obiektu this.props. Zwróć uwagę, że tym razem nie wywołuję na jej rzecz funkcji bind! Robię tak dlatego, że nie chcę aby metoda ta wywołała się w kontekście tego komponentu tylko w kontekście kontenera implementującego tę metodę.

Podsumowując: komponent prezentacyjny wie jak wyświetlać dane, które pobiera z zewnątrz i nie manipuluje nimi w żaden sposób.

Funkcjonalne komponenty bezstanowe

Jeśli jesteśmy przy komponentach prezentacyjnych to warto jeszcze wspomnieć o ciekawym podejściu, które warto w tym kontekście stosować. Jeśli komponent prezentacyjny nie posiada stanu, a zwykle nie posiada, to lepiej jest go zaimplementować jako funkcjonalny komponent bezstanowy (ang. functional stateless component).

UWAGA! Podejście to można stosować od momentu pojawiania się wersji v0.14 Reacta, więc jeśli używasz starszej wersji to czym prędzej ją zaktualizuj!

Ogólnie rzecz biorąc, do deklaracji komponentu, który nie posiada stanu nie musimy koniecznie używać klasy. Zamiast tego możemy zastosować “arrow function” czyli nowy zapis funkcji anonimowych dostępny w ES6. Najlepiej będzie pokazać to na przykładzie, dlatego też weźmy na tapetę komponent TodoList i przeróbmy go na funkcjonalny komponent bezstanowy:

import React from 'react';

const TodoList = (props) => {
  return (
    <ul>
      {props.items.map(item => {
        return <li key={item.id}>{item.name}</li>
      })}
    </ul>
  );
}

export default TodoList;

W powyższym przykładzie zastąpiłem deklarację klasy przypisaniem funkcji anonimowej do stałej TodoList. Funkcja ta jako parametr przyjmuje obiekt zawierający przekazane atrybuty (czyli to samo co this.props w standardowym podejściu). Implementacja tej funkcji jest taka sama jak wcześniej implementacja metody render.

Dlaczego takie podejście jest lepsze? Po pierwsze uzyskujemy trochę uproszczoną implementację… Po drugie zmniejsza się pokusa aby jednak dodać ten wewnętrzny stan lub użyć metod obsługi cyklu życia aplikacji ReactJS. Jeśli więc Twoją intencją jest aby komponent był faktycznie prezentacyjny, użyj właśnie tego podejścia.

Jest jeszcze jedna rzecz, która przemawia za takim podejściem do tworzenia komponentów prezentacyjnych. Zobacz poniżej, co możemy przeczytać w oficjalnej dokumentacji ReactJS:

In an ideal world, many of your components would be stateless functions. In the future we plan to make performance optimizations specific to these components by avoiding unnecessary checks and memory allocations.

When you don’t need local state or lifecycle hooks in a component, we recommend declaring it with a function. Otherwise, we recommend to use the ES6 class syntax.

Oznacza to, że w przyszłości możemy oczekiwać optymalizacji wydajnościowych związanych z funkcjonalnymi komponentami bezstanowymi. Dlatego też, moim zdaniem, warto używać ich już teraz!

Pewnie zapytasz: a co z propTypes?

Jeśli chodzi o funkcjonalne komponenty bezstanowe, to jak najbardziej możliwe jest dodanie sprawdzania typu parametrów przekazywanych do komponentu. Skoro jednak komponent taki nie jest klasą, to oczywiście nie odbywa się to przy użyciu statycznej właściwości klasy.

Zamiast tego, możemy zwyczajnie dodać właściwość propTypes do obiektu komponentu, tak jak na przykładzie poniżej:

TodoList.propTypes = {
  items: React.PropTypes.array.isRequired
}

Zadziała to tak samo jak w przypadku klasy.

Dlaczego warto stosować opisany podział odpowiedzialności?

Bez wątpienia zawsze warto stosować usystematyzowane podejście do tworzenia kodu. Opisany podział odpowiedzialności komponentów ReactJS na pewno jest sposobem na usystematyzowanie zasad tworzenia komponentów w projekcie.

Poza tym dzięki podziałowi na kontenery i komponenty prezentacyjne łatwiej jest utrzymać separację odpowiedzialności (ang. separation of concerns).

Tworzenie aplikacji za pomocą komponentów powinno też w założeniu ułatwić re-używalność kodu. Dzięki wydzielaniu komponentów prezentacyjnych, które wiedzą tylko o tym jak coś ma wyglądać ale nie interesuje ich manipulacja danymi, bardzo łatwo jest użyć ich w wielu różnych miejscach (kontenerach), podmieniając tylko źródło danych. Dzięki temu unikamy nie potrzebnych duplikacji kodu.

Podział odpowiedzialności komponentów ReactJS - podsumowanie

Moim zdaniem zawsze warto stosować przedstawiony podział odpowiedzialności komponentów ReactJS. Dzięki temu łatwiej jest zapanować nad kodem aplikacji, zapewnić re-używalność komponentów oraz separację odpowiedzialności. Mam nadzieję, że udało mi się to przedstawić w sposób zrozumiały dzięki czemu będziesz umiał zastosować to podejście także w swoim projekcie. A może masz jakiś lepszy pomysł jak ogarnąć projekt ReactJS?