Чувак, уже есть Pinia, Pinia Colada, TanStack Query для Vue, зачем ещё один способ управлять состоянием во Vue?

Да меня просто задолбало писать везде флаги отслеживания состояния для действий и делать store через фабричную функцию, как это делали наши праотцы в начале 2000-х. Запоминать зоопарк из разных composable-функций и хуков. Изучать какую-то другую систему реактивности, типа MobX, тоже не хочу. Это какой-то зашквар.

Я хочу использовать то, что есть: реактивность Vue везде, классы, методы. Хочу использовать классические шаблоны: SOLID, DDD и т.п. Хочу, чтобы Действие или запрос сами отслеживали и сообщали своё состояние. Хочу сконцентрироваться на бизнес-логике и писать меньше шаблонного кода.

И это возможно, покажу, как это работает на примере.

Постановка задачи

Пример будет максимально простым. 2 модели: пользователь и счётчик. Счётчик доступен только для аутентифицированного пользователя. По клику на кнопках пользователь может увеличить, уменьшить или сбросить счётчик.
Можно смотреть на это как на корзину, с возможностью положить, удалить товар или очистить.
Бизнес-логика максимально упрощена, чтобы показать подход и возможности.

Что будет в таком маленьком примере:

  1. отслеживание и отображение состояния каждого Действия;

  2. модели на классах;

  3. переиспользование модели в компонентах;

  4. зависимость одной модели от другой;

  5. разбиение на домены и слои;

  6. внедрение и инверсия зависимостей.

И для этого не нужно изучать новые подходы, архитектуру, сложное API и т.п.
Всё новое будет максимально приближено к стандартным возможностям TypeScript и Vue.

Пример реализован на Nuxt и Vue 3. Для реализации моделей, переиспользования и всего остального используем vue-modeler. vue-modeler помогает сделать всё описанное выше максимально простым образом.

Форма логина

Начнём с Vue-компонента, чтобы сразу были понятны преимущества.

Панель пользователя показывает форму логина или приветствие.
В шаблоне много элементов и условий, которые управляют отображением, но внутри <script> нет дополнительных переменных. Все условия используют состояние напрямую из действий, создавать и контролировать дополнительные вычисляемые свойства не нужно.

Код максимально читаемый. user.login.exec(value), user.login.isPending, user.login.error не требуют пояснений — так будет по всему проекту. Единый стандарт.

<template>
  <div class="user-panel">
    <template v-if="user.isGuest">
      <div class="user-panel__form">
        <label for="user-name">Name</label>
        <input
          id="user-name"
          v-model="name"
          type="text"
          placeholder="Enter name"
          :disabled="user.login.isPending"
        >

        <button
          :disabled="!name.trim() || user.login.isPending"
          @click="onLogin"
        >
          {{ user.login.isPending ? 'Logging in…' : 'Log in' }}
        </button>

        <p v-if="user.login.error" class="user-panel__error">
          {{ user.login.error.message }}
        </p>
      </div>
    </template>

    <div v-else class="user-panel__bar">
      <span>Hello, {{ user.name }}</span>
      <button
        :disabled="user.logout.isPending"
        @click="user.logout.exec()"
      >
        {{ user.logout.isPending ? 'Logging out…' : 'Log out' }}
      </button>
    </div>
  </div>
</template>

Получение user выглядит как обычная composable-функция.


<script setup lang="ts">
import { ref } from 'vue'
import { useUser } from '../dc'

const user = useUser()
const name = ref('')

async function onLogin() {
  const value = name.value.trim()
  if (!value) return
  user.login.exec(value)
}
</script>

Код компонента счётчика выглядит аналогично. Не буду загромождать статью — можно посмотреть здесь

Там есть особенность counter.hasPendingActions — встроенное свойство ProtoModel, которое равно true, если хотя бы одно действие модели выполняется. Не нужно проверять каждое действие отдельно.

Перейдём к определению модели пользователя.

Класс модели

Класс модели — это стандартный класс, унаследованный от ProtoModel. Методы мутации состояния обёрнуты в декоратор action.

ProtoModel помогает сделать экземпляр класса реактивным объектом, а декоратор преобразует методы в объекты в экземпляре. Эти объекты есть Действия.

Действие — это объект первого класса, который хранит операцию для изменения состояния модели, имеет методы управления выполнением и свойства для контроля состояния выполнения.

Действие за пределами класса нельзя вызвать как функцию, поэтому у него есть метод exec, который сохраняет сигнатуру исходного метода.

Если нужно следить за своим же действием внутри класса модели, то нужно получить его как объект. Это несложно. Подробнее здесь.
В остальном всё одинаково.

Несмотря на такие метаморфозы, проверки типов и подсказки TypeScript работают как надо. IDE будет корректно подсказывать свойства и методы, навигация внутри IDE будет работать правильно, а не как в Pinia.

Действие никогда не выбрасывает ошибки выполнения. Оно перехватывает их и сохраняет в свойстве error как специальный объект ActionError. Этим обеспечивается единый и одинаковый механизм обработки ошибок между моделью и UI-компонентом.

Полный код класса модели пользователя:

import { ProtoModel, action } from '@vue-modeler/model'

interface ApiService {
  login: (name: string) => Promise<string>
  logout: () => Promise<void>
}

export class User extends ProtoModel {
  protected _jwt = ''
  protected _name= ''

  constructor(
    private apiService: ApiService,
  ) {
    super()
  }

  get name(): string {
    return this._name
  }

  get isGuest(): boolean {
    return !this._jwt
  }

  @action async login(name: string): Promise<void> {
    this._jwt = await this.apiService.login(name)
    this._name = name
  }

  @action async logout(): Promise<void> {
    await this.apiService.logout()
    this._jwt = ''
    this._name = ''
  }
}

В результате:

  • Можем отслеживать статус выполнения без написания доп. кода.

  • Из коробки доступны блокировка, отмена выполнения Действия как методы.

  • Работа с действиями и обработка ошибок однообразна и предсказуема.

  • Код бизнес-логики содержит только логику изменения состояния модели.

  • Статус выполнения, ошибки — это свойства Действия.

  • Объём кода меньше в разы по сравнению с другими подходами.

Создаём экземпляр пользователя

Чтобы получить модель, нужно создать экземпляр. Для этого в каждом классе есть статический метод model. Он унаследован из ProtoModel. Сигнатура метода соответствует сигнатуре конструктора. В остальном всё стандартно. Проверка типов работает.

import { User } from './user/user'
import { apiService as userApiService } from './user/api-service'

const user = User.model(userApiService)

User.model(...) под капотом создаёт new User(apiService) и оборачивает экземпляр в shallowReactive, делая его реактивным для Vue. Можно создавать свои статические конструкторы.
Подробнее здесь.

Переиспользование

Модель отвечает только за состояние. За переиспользование и жизненный цикл модели отвечает контейнер. Здесь соблюдён принцип единственной ответственности.
Чтобы переиспользовать модель, её нужно зарегистрировать в контейнере.

import { provider } from '@vue-modeler/dc'
import { User } from './user/user'
import { apiService as userApiService } from './user/api-service'

export const useUser = provider(() => User.model(userApiService))

Результат регистрации — привычная функция-провайдер в виде useUser(). Её нужно использовать в UI-компонентах и других фабричных функциях. Она всегда вернёт один и тот же экземпляр. Изменение состояния в одном месте мгновенно отражается во всех остальных.

Контейнер создаёт модель 1 раз при первом вызове провайдера. Он отслеживает использование модели, автоматически удалит, если она больше не используется. При удалении он вызовет destructor модели. Он есть во всех моделях, унаследован от ProtoModel.
Можно создать постоянные модели, они живут после использования.

Вот пример переисползования:

<!-- app.vue -->
<script setup lang="ts">
import { useUser } from './dc'

const user = useUser()
</script>
<!-- counter/app-counter.vue -->
<script setup lang="ts">
import { useCounter, useUser } from './dc'

const counter = useCounter()
const user = useUser()
</script>

Подробнее в документации контейнера зависимостей.

Расслоение

Определение модели как класса инкапсулирует бизнес-логику в одном файле и заставляет выносить логику получения/отправки данных, отображения в другие файлы. Мы получаем расслоение на бизнес-логику, инфраструктуру и представление на уровне структуры файлов.

Вот так выглядит структура проекта:

app/
├── dc.ts                     # контейнер: регистрация и связывание моделей
├── app.vue                   # корневой компонент (представление)
├── user/
│   ├── user.ts               # модель (бизнес-логика)
│   ├── user-panel.vue        # компонент (представление)
│   └── api-service.ts        # сервис (инфраструктура)
└── counter/
    ├── counter.ts            # модель (бизнес-логика)
    ├── app-counter.vue       # компонент (представление)
    └── api-service.ts        # сервис (инфраструктура)

Каждый домен (user, counter) содержит три слоя:

  • модель (user.ts, counter.ts) — бизнес-логика и состояние;

  • представление (user-panel.vue, app-counter.vue) — UI-компоненты;

  • инфраструктура (api-service.ts) — взаимодействие с внешним миром (API, хранилища).

Внедрение

Counter зависит от API и от User, т.е. существует зависимость между доменами и слоями.
Все зависимости определяем через интерфейсы. Файл с классом модели больше не содержит прямые импорты, только интерфейсы. Модель ничего не знает о других доменах или слоях, её легко переиспользовать в других проектах.

import { ProtoModel, action } from '@vue-modeler/model'
import type { ShallowReactive } from 'vue'

interface ApiService {
  init: () => Promise<number>
  inc: (currentCount: number) => Promise<number>
  dec: (currentCount: number) => Promise<number>
}

interface User {
  isGuest: boolean
}

export class Counter extends ProtoModel {
  protected _count = 0

  constructor(
    private apiService: ApiService,
    private user: ShallowReactive<User>
  ) {
    super()
    // ...
  }

  // ...
}

Counter не импортирует класс User и не знает о конкретном apiService. Зависимости приходят через конструктор. Разработчик регистрирует в контейнере фабричную функцию для получения модели, в ней он получает зависимости через функции-провайдеры, передаёт зависимости в конструктор модели:

import { provider } from '@vue-modeler/dc'
import { Counter } from './counter/counter'
import { apiService as counterApiService } from './counter/api-service'
import { User } from './user/user'
import { apiService as userApiService } from './user/api-service'

export const useUser = provider(() => User.model(userApiService))
export const useCounter = provider(() => Counter.model(counterApiService, useUser()))

useUser() внутри фабрики useCounter — это тот же самый экземпляр User, что используется в компонентах.

Все границы выстроены через интерфейсы — как завещали предки.
Проект приобретает чёткую структуру и дизайн по доменам и слоям.
Это очень сильно улучшает DX.

Зависимые состояния

Counter инициализирует счётчик, когда пользователь залогинился. Чтобы это реализовать:

  1. Передаём User как зависимость в конструктор Counter.

  2. Создаем наблюдатель за user.isGuest через this.watch.

  3. В наблюдателе вызываем Действие.

export class Counter extends ProtoModel {
  protected _count = 0

  constructor(
    private apiService: ApiService,
    private user: ShallowReactive<User>
  ) {
    super()

    this.watch(
      () => this.user.isGuest,
      (isGuest: boolean) => {
        if (isGuest) {
          this.reset()
          return
        }

        this.init()
      },
      { immediate: true }
    )
  }

  @action async init(): Promise<void> {
    this._count = await this.apiService.init()
  }
  
  ...
}

Когда пользователь логинится (isGuest становится false) — Counter автоматически загружает начальное значение через this.init(). Когда пользователь разлогинивается — счётчик сбрасывается через this.reset().

Важно использовать this.watch, а не просто watch из Vue. Подробнее в разделе Наблюдатели.

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

Модель — экземпляр стандартного класса. Зависимости легко внедряются через конструктор. Написание юнит-тестов — стандартный процесс. При тестировании моделей нужно помнить о том, что это реактивный объект, и нужно вызывать nextTick для обновления реактивности.

Зависимости мокаются через конструктор — никаких магических хелперов, DI-фреймворков или глобального состояния.

Тесты на модели можно посмотреть в репозитории проекта: user.test.ts, counter.test.ts

Итого

Даже на таком маленьком примере мы получили:

  1. значительное уменьшение кода: нет шаблонного кода, нет лишних вычисляемых свойств;

  2. чёткие границы доменов и слоёв на уровне семантики и файловой структуры;

  3. слабую связанность через интерфейсы и внедрение зависимостей;

  4. автоматическое следование практикам чистого кода;

  5. единые стандарты обработки ошибок;

  6. стандартные подходы к тестированию;

  7. единообразный код работы с Действиями независимо от разработчика.

Есть неочевидные возможности:

  1. Действие — это объект первого класса, который сохраняет контекст модели. Его можно передавать и использовать везде отдельно от модели.

  2. Состояние модели инкапсулировано в объекте, а не хранилище. Объекты легко создавать динамически в рантайме и передавать куда угодно.

Всё это позволяет реализовывать такие шаблоны, как CommandExecutor, Repository и т.п. Код становится максимально однообразным. Это значит: лучше DX, легче писать промты. Но об этом отдельно.

Документация vue-modeler.

Я автор этого проекта. Мы используем его в проде. Уже есть сформированные шаблоны и для решения типичных и нетипичных задач. При наличии времени и возможностей планирую писать о них у себя в телеграме.

Комментарии (18)


  1. alexey_ym
    16.03.2026 18:39

    https://www.npmjs.com/package/vue3-native-decorators


    1. abratko Автор
      16.03.2026 18:39

      похоже =) ... и это прикольно, честно. Я рад видеть похожие паттерны, это значит я (мы) попали туда, куда нужно. Это значит, что есть "боль", которую хочется закрыть.
      Но я не хочу учить декораторы, когда shallowReactive сделает все свойтва объекта, кроме приватных, реактивнымыми. Я хочу минимум нововведений.

      После создания модели - все свойства, кроме приватных будут реактивными без всяких декораторов.

      Я не хочу помнить о том, что нужно где-то вызвать destructor. И я не думаю, я просто использую контенер.

      @alexey_ym, но это круто


  1. zakharrr
    16.03.2026 18:39

    Круто, что показали ООП-подход на реальном примере. А как библиотека ведёт себя в больших приложениях — нет ли проблем с отладкой реактивности внутри классов?


    1. abratko Автор
      16.03.2026 18:39

      нет, проблем не было. Но тут работают только стнадартные инструменты: debugger и console.


  1. danilovmy
    16.03.2026 18:39

    Понимаю, что это только пример, но зачем watch за юзером в счетчик? Можно же проверить в момент клика, и я бы даже сказал, что и с watch стоит проверять состояние залогиненности юзера на момент клика.


    1. abratko Автор
      16.03.2026 18:39

      Можно и так. Здесь нужно смотреть и разбираться с контекстом.

      Например, что если логин не только по кнопке, а по событию из другой вкладки или микрофронта?

      В примере, я создал связь на уровне моделей в слое приложения. Потому что она существует в бизнес-домене. Мне не нужно думать о том, что это событие должно быть где-то в другом месте проверено, обработано и вызвана загрузка. И таких мест может быть несколько. В компоненте(ах) я просто отображаю состояние и не парюсь .


  1. ArutaGerman
    16.03.2026 18:39

    Зачем вы тащите классы в Vue? Что мешает написать композабл? Любой класс можно переписать на композабл и это:

    • Следует парадигме Vue

    • Использовать все возможности Vue

    • Расширяемо

    • Переиспользуемо

    • ООП-эшно, внезапно! Ибо композабл можно использовать в другом композабле, да хоть цепочку устроить в N потомков

    • Повышает комфорт разработки ибо не развивает зоопарка, где часть использует композаблы, а другая, видимо "трушная" часть использует классы и не приходится склеивать 2 системы, а пользуемся тем что даёт фреймворк из-под капота

    И да, если цель "не учить новое", то это и не надо, т.к. используются те же возможности, что и в клмпонентах


    1. abratko Автор
      16.03.2026 18:39

      Одна из задач , это создать модели с поведением и изменением состояния динамические и передавать куда угодно.

      Вторая — брать в любом месте модель и не думать о иерархии и структуре компонентов внутри слоя представления.

      Третья - перестать писать шаблонный код, перепишите пример из статьи на composable или pinia, так чтобы он обрабатывал все ошибки и состояния, мог отменять или блокировать выполнение. И кода будет больше чем в 2 раза.

      А как вы внедряете зависимости в composable ?

      Коспозаблы это не про модель , это про разбиение компонента на части и лучшая организация кода, по сравнению с миксинами. Это переиспользование в слое представления.


      1. zede
        16.03.2026 18:39

        Скажу честно, не читал статью и исходный код, сразу пошел к комментам

        Все-таки композаблы как концепт полностью самодостаточны и вообще не обязаны быть примитивным "распилом компонента".
        1. Все что может ООП работает и на композаблах (исключение, это наследование и проверки на instanceof что в обычном коде нужно примерно никогда), зато КОМПОЗИЦИЯ работает значительно проще и удобнее вместо наследования, а вот с классами с ней несколько больнее
        2. Вместо наследования можно применять композицию. Никто не запрещает создать функцию высшего порядка. Например твой DI я достаточно давно в рамках эксперимента реализовывал как раз в рамках sharedComposable не ломая начальную концепцию с композаблами

        3. Это все еще несколько отдельное знание которое надо учить. Композабла вообще не обязаны иметь связанность с компонентами. Их можно просто воспринимать как фабрику и все.

        4. Классы имеют достаточно много избыточной семантики и функционала. А еще нужно суметь выстроить все правильно чтобы не получить утечку памяти. Я сходу могу сказать, что вам понадобился деструктор за которым теперь нужно слеедить в ручном режиме, чтобы очищать effecScope, либо полагаться только на поведение sharedComposable.. но тогда классы приобретают определенную семантику и мы можем их создавать только в рамках того когда трекинг возможен. Композаблы используемые как генераторы объектов таких же тоже склонны к такой проблеме (так как в базовом сценарии scope имеет родительский который сам проследит за очисткой)

        В общем и целом классы создают гораздо больше проблем чем решают, а семантическую разница между фабрикой и классом не сильно велика


        1. abratko Автор
          16.03.2026 18:39

          Ну вот вы зря не читали, не нужно следить за деструктором. И дело вообще не в классах.
          Одна из причин — устранение частого повторяющегося одинакового кода.

          Перепишите это на композабл, обработайте все ошибки в каждом действии, добавьте возможность отмены и блокировки. И просто посмотрите объём кода.

          После этого посмотрите как этот пример сделает ваш коллега. Он точно сделает что-то не так как вы в обработке ошибок и именовании переменных. Теперь умножьте это на 10.

          Сейчас вы скажете можно создать свою библиотеку и сделать служебные функции, что бы избежать повторении. И так в каждом проекте , и вот у нас набор служебных функций типа useVue, или nuxt. И это не остановить


      1. ArutaGerman
        16.03.2026 18:39

        Композаблы это не про разбиение кода компонента.

        Композаблы про создание самодостаточного или служебного кода: про вычисления, про что угодно - зависит лишь от задачи. Это просто фабрика.

        Композабл может быть создан как для компонента, так и для других композаблов, а так же и для использования внешним потребителем (экспортируем из ui, например).

        Мы в проекте сейчас, например, используем наравне Pinia и композаблы:

        • Pinia удобна для стора, остальное - композаблы

        • Некоторым сторам создали фабрики для создания разных инстансов сторов со своими дополнениями/изменениями, но в базе своей использующих 1 стор - аналог наследования...

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

        • Есть композаблы, которые являются адаптерами - снова что-то из ООП...

        Как видим принципы ООП прекрасно ложатся на композаблы.

        По вашим ответам/вопросам поясню, что композабл:

        1. Можно передать куда угодно и состояние динамическое: хотите инкапсуляцию и персональное состояние на композабл - пожалуйста, хотите синглтон - пожалуйста

        2. Не надо думать с композаблом об этом - используем где надо и только то что надо. Хоть в композабле, хоть в классе можно написать 100500 методов, которые будут выполнять только console.log, но это не значит что он привязан к конкретному компоненту - всё зависит от того что написать в композабле

        3. Кода будет не драмматически больше, а иногда и меньше, ибо мы так же будем использовать другой композабл и, что более полезно, мы будем явно видеть откуда у нас та или иная сущность взялась и при больших цепочках наследования защищены от возможности что-то сломать или изменить поведение, как это при наследовании может произойти, т.к. мы явно видим что используем и при6имаем решение осознанно

        4. Про внедрение уже сказал: мы можем хоть паровоз из композаблов построить в 100500 этажей. А если надо где-то что-то взять и сунуть в прототип как в классах, чтобы доступно стало всем, то - спасибо - не хочу искать это место "где-то в проекте", чтобы разгребать почему метод не такой как ожидается


        1. abratko Автор
          16.03.2026 18:39

          Не буду спорить про компосабл, в оф. документации всё написано.

          У вас просто на уровне определения свойств количество кода увеличится. Не говоря про обработку ошибок и отслеживание состояния выполнения. Придётся копировать одно и тоже. Везде будет isloading или ispending, try catch похожие друг на друга.

          Я говорю это, потому что я также писал. Я прошел этот путь. Всё было прям как у вас.

          Я даже хочу поднять старый код на компосаблах и просто визуально показать разницу.

          Видимо придётся переписать самому пример на комплзаблах и опубликовать сравнение


          1. ArutaGerman
            16.03.2026 18:39

            Про кол-во определения свойств такое может быть, но зависит от того как написать композабл и от преследуемой цели. Это может быть как портянка свойств, так и 1 свойство, это может быть функция, извлекающая нужные свойства и вот мы уже 20 строк перечисления свойств родительского композабла превратили в 1 строку.

            А чтобы не обмазываться проверками дополнительными, мы же можем создать функцию высшего порядка, которая за нас это будет делать? Определить в любом композабле её можно, да хоть как хелпер отдельный - вот уже и решили эту проблему.

            А, если это запросы API, то вообще интерцепторы используем и catch становится нужен в исчезающе редких случаях, а где нужно, там он скорее всего будет специфичную логику иметь для конкретной реализации и это в дополнение к тому, что прописано в интерцепторе, т.к. логика в нём максимально базовая и агностичная к пользователю вызова.


            1. abratko Автор
              16.03.2026 18:39

              А чтобы не обмазываться проверками дополнительными, мы же можем создать функцию высшего порядка, которая за нас это будет делать?

              об этом написано во втором абзатце, я хочу уйти от создания функций. И создания своей библиотеки функций. В каждой компании уже по такой библиотеке. Еще намешано с другими.
              Не понять, где композабле для модели, а где служебный. Или тут всё в перемешку? что же делать? Ну... сделаем еще один. Да..ю это же просто. Просто функция.

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

              Я хочу помнить максимум 3 вещи: унаследовать от Protomodel, action для асинхроных мутаций и регистрация в контейнере. Остальное должна подсказывать IDE.

              А, если это запросы API, то вообще интерцепторы используем и catch становится нужен в исчезающе редких случаях,

              везёт вам...


        1. abratko Автор
          16.03.2026 18:39

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


          1. ArutaGerman
            16.03.2026 18:39

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

            Что так же применимо и к классам


            1. abratko Автор
              16.03.2026 18:39

              Нет , совсем не применимо , потому что композиция в классах, это внедрение, построенное на интерфейсах. А наследования там почти нет.


              1. ArutaGerman
                16.03.2026 18:39

                Ваш пример я понял как "из домена финансов нам надо взять класс в домен заказа".

                Если вы берете класс из финансов и в заказах делаете внедрение, то в этом случае я бы предпочел вынести из финансов класс в общий домен, условный shared, и тогда домены не будут иметь связи друг с другом.

                А это значит, что описание класса существует в общем домене, в финансах будет просто создание экземпляра, а в заказах создание и внедрение в этот класс.

                Аналогично делается и с композаблами: помните выше я писал про фабрики и функции высшего порядка?

                Если уж так хочется, то и в композабл можно сделать внедрение как в класс (таким же механизмом), но тогда мы снова возвращаемся к вопросу: зачем?

                Я бы еще понял классы в Vue 2, но в 3...

                Полного аналога внедрения как в классе у композабла нет, конечно, но есть другие механизмы для реализации и причина, по которой вами выбраны классы, скорее в привычке использования классов, а не в том что описано в статье и в комментариях.