Обсудим: composable, sharedComposable, pinia. Боли, кторые я испытал, когда использовал Pinia. Какие существуют подводные камни в управлении состоянием. Методы и идеи их преодоления. Альтернативы, готовые библиотеки.
Контекст статьи — PWA, в других контекстах тоже будет работать, но это не точно.
В статье будет критика Pinia. Если ты любишь этот инструмент, и не готов к конструктивной критике — лучше не читать, либо давай подискутируем в комментах. Я открыт к диалогу. Если ты уже думаешь о замене или уже ищешь её — это для тебя.
Кратко по определениям
Composable — фабричная‑функция, которая создает объект для хранения состояния. Каждый вызов создает новый объект. Оф. документация рекомендует использовать Composable как замену миксинам. Это работает так:
Компонент вызывает composable функцию.
Она создает всегда новый объект, который хранит реактивное состояние в контексте компонента.
Компонент использует объект, его свойства, методы, меняет состояние.
При уничтожении компонента контекст уничтожается вместе объектом и состоянием.
Основное назначение — переиспользование одинакового функционала и создание локального состояния в разных компонентах.
Shared Composable отличается от composable:
Создает новый объект, но в отдельном реактивном контексте. Новый контекст не связан с вызывающим компонентом, это позволяет хранить объект после удаления компонента.
Имеет счетчик вызовов. При вызове в компоненте счетчик увеличивается, при уничтожении компонента — уменьшается. Новый объект создается только, когда счетчик нулевой. Уничтожается, когда стал нулевым. В остальных случаях возвращается существующий.
Основное назначение — создание, использование общего состояния, пока оно необходимо хотя бы одной компоненте.
PInia — глобальный менеджер состояний.
Создает объект состояния во время первого к нему обращение и больше никогда не удаляет. Объект состояние создается в отдельном от компонента реактивном контексте.
Автоматически восстанавливает состояние при использовании SSR.
Зачем, если есть Pinia?
Наверно…, если с Pinia нет проблем, то и не нужно. Но чем больше проект, сложнее связи состояний, бизнес логика и UX, тем больше подводных камней может показать Pinia. Особенно, если хочется переиспользовать бизнес-логику в другом проекте.
Вот мои боли:
Store нужно очищать и удалять, если он не используется. Если этого не сделать, возможны неожиданные эффекты. Об этом нужно помнить. И даже здесь есть особенности:
$dispose не удаляет состояние. При повторном использовании store его состояние будет восстановлено до предыдущего. Чтобы этого избежать, нужно удалить состояние вручную: delete pinia.state.value[store.$id]
Очистка состояния - дополнительный однотипный код внутри компонент.
Store может быть композицией нескольких store или зависеть от них. Pinia ничего не знает о композиции и зависимостях, поэтому стандартные методы очистки reset и dispose здесь не подходят. Придётся самостоятельно придумывать, как очищать. А теперь представьте: один из store, который часть композиции, используются в нескольких местах. Ну и ... как понять — что нужно очищать, а что нет?
Изменение иерархии компонент может потребовать переноса очистки store в другое место, за этим нужно следить.
Фабричные функции store в том виде, в котором они описаны в доке:
Сильно усложняют навигацию в IDE. Никогда не попадешь на определение метода —только в return или интерфейс. Особенная боль, когда их много и они большие.
Выглядят как код начала 2000-x. Так писали, когда не было классов и TypeScript. В то время возможности языка для создания объектов и инкапсулирования были сильно ограничены. Самое печальное, на мой взгляд, что начинающие разработчики читают это и думают, что это нормально, так и надо. По‑моему, это деградация.
defineStore не явно меняет интерфейс объекта, созданного фабричной функцией.
Об этом нужно помнить, особенно, если использовать деструктуризацию свойств.
Если возникла необходимость composable сделать общим через defineStore, придется исправлять весь код, который его использовал, потому что интерфейс изменится.
Сложные конструкции из типов в TypeScript.
Особенности при SSR. pinia.state.value хранит только то, что вернул публичный интерфейс объекта. Если после гидрации нужно манипулировать состоянием, то можно получить неожиданное поведение, потому что внутреннего состояния не существует. Здесь приходится придумывать разные костыли.
Пример: store категорий имеет публичные интерфейсы для получения категорий в виде плоского списка, дерева и категорий по текущему URL. Все интерфейсы возвращают данные, построенные на основе одной внутренней структуры. Pinia всё сериализовала, не спрашивая нас. После восстановления состояния на клиенте PWA будет как минимум 2 проблемы:
Все объекты в этих структурах будут разные, даже если у них одинаковый id, потому что они были сериализованы по отдельности. Будь к этому готов, если ты сравниваешь в коде объекты.
Попытка манипулирования состоянием может вызвать непонятные ошибки, потому что истинное (внутреннее) состояние пустое и не было сериализовано.
Pinia — это искуственная абстракция. Это отдельная библиотека, которая оборачивает реактивность Vue в какое‑то отдельное API, чтобы …. что? Зачем? Честно говоря, я не нашел внятного ответа на этот вопрос. Давайте поищем вместе в комментариях.
Тестирование store — это отдельная история с композицией и плагинами.
Мой вывод: Pinia пытается решить много проблем и не решает ни одну до конца.
Почему Shared Composable (SC)?
Потому что закрывает 4 из 6 болей описанных выше:
store не нужно очищать и удалять
SC никак не связан с хранением состояния. SC больше похож на контейнер зависимостей, типа https://github.com/microsoft/tsyringe. Токеном доступа к зависимости является функция‑провайдер. Хранимый объект может содержать реактивное состояние, но это не обязательно.
Объекты удаляются автоматически, если не используются. Поэтому нет проблем с композицией состояний и зависимостями: всё, что используется останется, остальное будет удалено.
Нет дополнительного кода очистки. Меньше кода — меньше проблем и ошибок.
Вместо composable можно всегда смело использовать SC. Объект будет удален, если не используется.
Результат фабричной функции хранится как есть, без изменения интерфейса.
Ничто не мешает использовать внутри фабричной функции new SomeClass. При таком подходе навигация по коду будет простой: вы будете попадать точно на определение метода или свойства в классе, а не куда‑то в return.
Тип останется без изменений, нет сложных вычисляемых типов.
Если у вас есть composable, его легко можно сделать общим, вообще не трогая код, который его использовал.
Всё API — это одна функция, которая регистрирует фабричную функцию в контейнере и создает функцию‑провайдер. Но эта функция делает очень важную вещь — позволяет очень легко отделить логику и состояние от представления.
Какие есть альтернативы?
Код создания SC не сложен, вы можете сами написать такую функцию, и всё заработает. Пример можно посмотреть здесь.
Если в проекте есть useVue, то там есть createSharedComposable.
Сейчас активно развивается Tanstack для Vue
Еще есть vue‑modeler, там есть DI, расширенные возможности контроля действий, интеграция с SSR. Она закрывает все боли, которые есть у PInia. Отдельная статься на Хабре здесь.
