Хранение состояния в композитах Vue 3

Composition API – одно из главных преимуществ Vue 3, позволяющее создавать логику, которую легко переиспользовать в разных компонентах. Но с этой гибкостью приходят и новые вопросы: где хранить состояние композитов и как разные подходы влияют на приложение?

В этой статье рассмотрим основные подходы к управлению состоянием в композитах, их влияние на производительность и совместимость с SSR.

Основы реактивности

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

  • ref – обертка над значением любого типа. Требует обращения через .value. Если внутри объект, он автоматически становится реактивным на всю глубину.

  • reactive – работает только с объектами, проксируя их. Не требует .value, но теряет реактивность при деструктуризации (если не использовать toRefs).

  • shallowRef и shallowReactive – поверхностные версии, которые не делают вложенные свойства реактивными. shallowRef следит только за заменой самого .value, а shallowReactive – только за свойствами верхнего уровня.

Теперь рассмотрим, где и как лучше хранить эти реактивные данные в контексте переиспользуемых композитов.

Паттерн 1: Локальное состояние

Самый чистый и предсказуемый подход – создавать новый экземпляр состояния при каждом вызове композита:

// useCounter.ts
import { ref } from 'vue';

export function useCounter(initialValue: number = 0) {
  const count = ref(initialValue);
  const increment = () => count.value++;
  const decrement = () => count.value--;

  // Состояние создается ВНУТРИ функции
  return { count, increment, decrement };
}

// Использование в компоненте
// const { count: countA, increment: incrementA } = useCounter(10);
// const { count: countB, increment: incrementB } = useCounter(100);
// countA и countB – два разных, независимых счетчика

Преимущества:

  • Изоляция данных: Каждый экземпляр композита имеет свое независимое состояние.
  • Предсказуемость: Поведение понятное и легко тестируемое.
  • SSR-совместимость: Идеально для серверного рендеринга – каждый запрос создает свои экземпляры состояния без протечек между клиентами.

Когда использовать: Практически всегда. Формы, локальные UI-состояния, таймеры, логика взаимодействия с API – все это отлично работает с локальным состоянием.

Совет: Если состояние не должно напрямую изменяться извне, оборачивайте его в readonly():

return { count: readonly(count), increment, decrement };

Паттерн 2: Глобальное состояние в модуле

В этом подходе состояние живет на уровне JavaScript-модуля:

// store/useTheme.ts
import { ref, readonly } from 'vue';

// Состояние объявлено ВНЕ функции!
const currentTheme = ref('light');

export function useTheme() {
  const toggleTheme = () => {
    currentTheme.value = currentTheme.value === 'light' ? 'dark' : 'light';
  };

  // Все вызовы useTheme будут работать с одним и тем же currentTheme
  return { theme: readonly(currentTheme), toggleTheme };
}

Преимущества:

  • Простота: Минимум кода для создания общего состояния.
  • Единый источник истины: Все компоненты работают с одними данными.

Недостатки:

  • Несовместимость с SSR: Критический недостаток! При серверном рендеринге модуль инициализируется один раз на сервере, и все пользователи будут видеть одно и то же состояние.
  • Сложность отладки: При росте приложения может стать неочевидно, откуда приходят изменения.

Когда можно (осторожно) использовать:

  • Только клиентский рендеринг: Для простых случаев и некритичных настроек.
  • Константы и конфигурация: Для неизменяемых данных, одинаковых для всех пользователей.

Паттерн 3: Композиты-фабрики с параметрами

Развитие первого паттерна, где композит принимает параметры, влияющие на его поведение:

import { ref, watch, isRef, onMounted, Ref, unref } from 'vue';

export function useAsyncData(
  fetchFn: (params: P) => Promise,
  params?: Ref | P,
  immediate: boolean = true
) {
  const data = ref(null);
  const isLoading = ref(false);
  const error = ref(null);

  const execute = async (runtimeParams?: P) => {
    const currentParams = runtimeParams ?? unref(params);
    isLoading.value = true;
    error.value = null;

    try {
      data.value = await fetchFn(currentParams as P);
    } catch (e: any) {
      error.value = e;
      data.value = null;
    } finally {
      isLoading.value = false;
    }
  };

  if (isRef(params)) {
    watch(params, execute, { immediate });
  } else if (immediate) {
    onMounted(() => execute(params as P));
  }

  return { data, isLoading, error, execute };
}

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

Когда использовать: Для повторяющихся операций с разными входными данными – работа с API, фильтрация, пагинация и т.д.

Совет: Используйте watch для отслеживания изменений внешних реактивных параметров и обновления состояния композита соответственно.

Паттерн 4: provide/inject – состояние для поддерева

Когда состояние должно быть доступно нескольким компонентам в определенной ветке компонентного дерева:

// types/form.ts
import type { InjectionKey, Ref } from 'vue';

export interface FormContext {
  isSubmitting: Ref;
  errors: Ref>;
  registerField: (name: string) => void;
}

export const formContextKey: InjectionKey = Symbol('formContext');

// FormProvider.vue
// 
import { provide, ref } from 'vue';
import { formContextKey } from '../types/form';

const isSubmitting = ref(false);
const errors = ref({});
const fields = ref([]);

const registerField = (name: string) => {
  fields.value.push(name);
};

provide(formContextKey, {
  isSubmitting,
  errors,
  registerField,
});

// useFormField.ts
import { inject, computed } from 'vue';
import { formContextKey } from '../types/form';

export function useFormField(fieldName: string) {
  const formContext = inject(formContextKey);
  
  if (!formContext) {
    throw new Error('useFormField должен использоваться внутри FormProvider');
  }

  formContext.registerField(fieldName);
  
  const error = computed(() => formContext.errors.value[fieldName]);

  return {
    error,
    isSubmitting: formContext.isSubmitting,
  };
}

Преимущества:

  • Избегаем prop drilling: Не нужно передавать пропсы через множество промежуточных компонентов.
  • Явная область видимости: Данные доступны только в поддереве компонентов.
  • SSR-совместимость: Каждое дерево компонентов имеет свой контекст.

Когда использовать: Для компонентов, которые логически связаны и работают вместе – формы, мастеры настройки, выпадающие меню с подменю и т.д.

Совет: Используйте InjectionKey для типизации и Symbol для уникальных ключей, чтобы избежать конфликтов имен.

Оптимизация производительности с shallowRef и shallowReactive

Для больших структур данных, особенно когда вы не нуждаетесь в реактивности на всю глубину:

export function useItems(initialItems = []) {
  // Только .value будет реактивным, внутренняя структура - нет
  const items = shallowRef(initialItems);
  
  const setItems = (newItems) => {
    items.value = newItems; // Это вызовет обновление
  };
  
  const updateItem = (index, newValue) => {
    const newItems = [...items.value];
    newItems[index] = newValue;
    items.value = newItems; // Нужно заменить всю ссылку
  };
  
  return { items, setItems, updateItem };
}

Когда использовать:

  • Для больших массивов или объектов, где слежение за каждым вложенным свойством избыточно
  • Когда вы обновляете данные целиком, а не отдельные свойства
  • При интеграции с внешними системами управления состоянием

А что насчет Pinia/Vuex?

Эти библиотеки по-прежнему отлично подходят для управления сложным глобальным состоянием. Pinia прекрасно интегрируется с Composition API и поддерживает SSR. Используйте ее, когда:

  • Требуется сложная логика управления состоянием
  • Нужны девтулзы для отладки
  • Нужна поддержка плагинов (например, для персистентности)

Подведем итоги:

  1. Локальное состояние - золотой стандарт. Предсказуемое поведение, простота тестирования, полная SSR-совместимость. Не усложняйте без явной необходимости.

  2. Глобальное состояние (хранение в модуле) - допустимо только при клиентском рендеринге и для простых случаев. С SSR - категорически несовместимо.

  3. shallowRef и shallowReactive - незаменимы при работе с большими структурами данных, особенно когда не требуется глубокая реактивность. Используйте для оптимизации рендеринга крупных списков.

  4. readonly() - делает API ваших композитов безопаснее, предотвращая неконтролируемые изменения состояния извне.

  5. provide/inject - идеальное решение для передачи состояния вглубь дерева компонентов без пропсов. Не забывайте про типизацию через InjectionKey.

  6. Pinia - когда действительно нужно управлять сложным глобальным состоянием. Отлично работает с Composition API и SSR, хотя для простых случаев может показаться избыточной.

Выбор подхода всегда зависит от конкретной задачи, но помните: чем проще и изолированнее ваше состояние, тем меньше головной боли при отладке и масштабировании приложения.