Container и Presentational компоненты

В компонентных фреймворках ключевую роль играет разделение логики и представления. В Stencil это разделение реализуется через подход Container и Presentational компонентов, заимствованный из функционального и реактивного программирования. Такой подход повышает читаемость кода, упрощает тестирование и делает архитектуру масштабируемой.

Container-компоненты отвечают за данные, состояние и бизнес-логику, тогда как Presentational-компоненты концентрируются исключительно на отображении и не знают, откуда пришли данные и что с ними происходит за пределами UI.


Container-компоненты

Container-компонент — это компонент-оркестратор. Он управляет состоянием, работает с API, слушает события и передаёт данные вниз по иерархии.

Характерные признаки:

  • Использование @State, @Prop({ mutable: true })
  • Вызовы API, асинхронная логика
  • Обработка пользовательских событий
  • Минимум или полное отсутствие HTML-разметки
  • Передача данных и колбэков дочерним компонентам

Пример container-компонента в Stencil:

import { Component, State, h } from '@stencil/core';

@Component({
  tag: 'user-container',
  shadow: true,
})
export class UserContainer {
  @State() users: Array<{ id: number; name: string }> = [];
  @State() loading = true;

  async componentWillLoad() {
    const response = await fetch('/api/users');
    this.users = await response.json();
    this.loading = false;
  }

  render() {
    return (
      <user-list
        users={this.users}
        loading={this.loading}
      />
    );
  }
}

Здесь компонент полностью сосредоточен на данных и жизненном цикле, а визуальное представление делегировано другому компоненту.


Presentational-компоненты

Presentational-компоненты (иногда называемые dumb-components) отображают данные и реагируют на переданные параметры, не храня собственного состояния и не выполняя бизнес-логику.

Основные свойства:

  • Используют только @Prop
  • Не обращаются к API
  • Не используют @State
  • Легко переиспользуются
  • Просты для unit-тестирования

Пример presentational-компонента:

import { Component, Prop, h } from '@stencil/core';

@Component({
  tag: 'user-list',
  shadow: true,
})
export class UserList {
  @Prop() users: Array<{ id: number; name: string }>;
  @Prop() loading: boolean;

  render() {
    if (this.loading) {
      return <p>Загрузка...</p>;
    }

    return (
      <ul>
        {this.users.map(user => (
          <li key={user.id}>{user.name}</li>
        ))}
      </ul>
    );
  }
}

Компонент полностью детерминирован: одинаковые props всегда приводят к одинаковому UI.


Поток данных и однонаправленность

Stencil по своей природе следует принципу однонаправленного потока данных:

  • Container хранит состояние
  • Presentational получает данные через props
  • Пользовательские действия передаются вверх через события

Для этого используются @Event и @Listen.

Пример передачи события из presentational-компонента:

import { Component, Event, EventEmitter, Prop, h } from '@stencil/core';

@Component({
  tag: 'user-item',
  shadow: true,
})
export class UserItem {
  @Prop() name: string;
  @Event() removeUser: EventEmitter<void>;

  render() {
    return (
      <div>
        <span>{this.name}</span>
        <button onCl ick={() => this.removeUser.emit()}>
          Удалить
        </button>
      </div>
    );
  }
}

Обработка события в container-компоненте:

<user-item
  name={user.name}
  onRemoveU ser={() => this.deleteUser(user.id)}
/>

Композиция вместо наследования

Stencil поощряет композицию компонентов, и разделение на container и presentational логично ложится на этот принцип.

Вместо одного большого компонента:

  • создаётся контейнер для логики
  • создаётся несколько простых визуальных компонентов
  • компоненты свободно комбинируются

Это снижает связанность и предотвращает разрастание компонентов до монолитов.


Повторное использование и дизайн-системы

Presentational-компоненты идеально подходят для построения дизайн-систем:

  • кнопки
  • списки
  • карточки
  • формы

Они не зависят от бизнес-контекста и могут использоваться в разных container-компонентах, а также в разных приложениях.

Stencil позволяет экспортировать такие компоненты как Web Components, что делает их независимыми от фреймворков.


Тестирование

Разделение container и presentational компонентов напрямую влияет на качество тестов:

  • Presentational-компоненты тестируются как чистые функции
  • Container-компоненты тестируются с моками API и событий

Пример теста presentational-компонента:

it('renders users', async () => {
  const page = await newSpecPage({
    components: [UserList],
    html: `<user-list></user-list>`,
  });

  page.root.users = [{ id: 1, name: 'Alex' }];
  page.root.loading = false;

  await page.waitForChanges();

  expect(page.root.shadowRoot.textContent).toContain('Alex');
});

Типизация и контракт компонентов

Чёткое разделение ролей упрощает работу с TypeScript:

  • props становятся явным контрактом
  • ошибки выявляются на этапе компиляции
  • компоненты документируются сами собой

Особенно важно явно типизировать входные данные presentational-компонентов, так как они используются многократно.


Практические рекомендации

  • Один container может управлять несколькими presentational-компонентами
  • Не помещать бизнес-логику в presentational-компоненты
  • Не использовать @State там, где достаточно @Prop
  • Разделять ответственность даже внутри одного feature
  • Избегать «умных» UI-компонентов с побочными эффектами

Такой подход формирует устойчивую архитектуру, в которой компоненты остаются простыми, предсказуемыми и удобными для поддержки в долгосрочной перспективе.