Skip to content

Dominando useState en Nuxt 4: Estado Global Reactivo y Simple

¿Qué es useState en Nuxt?

useState es el composable de Nuxt para manejar estado global y persistente entre componentes. Es la alternativa moderna y más simple a Pinia/Vuex, integrada directamente en Nuxt.

Características clave:

  • Estado global compartido entre componentes
  • Persistencia automática entre renders (SSR/CSR)
  • Tipeo TypeScript nativo
  • No requiere stores adicionales
  • Integración perfecta con el ecosistema Nuxt

Sintaxis Básica

ts
const state = useState<T>(key, initFn);

Parámetros:

  • key: String único para identificar el estado (obligatorio)
  • initFn: Función que devuelve el valor inicial (opcional)

Retorno:

  • Ref reactivo con el valor del estado

Diferencia: useState vs ref vs Pinia

CaracterísticauseStaterefPinia
AlcanceGlobal (app)Local (componente)Global
Persistencia✅ Entre renders❌ Solo en componente
SSR✅ Perfecto⚠️ Necesita adaptación⚠️ Necesita adaptación
ComplejidadSimpleMuy simpleComplejo
DevTools❌ No❌ No✅ Sí

Ejemplos Prácticos

Estado Global Simple

ts
// composables/useCounter.ts
export const useCounter = () => {
  const count = useState('counter', () => 0);
  
  const increment = () => {
    count.value++;
  };
  
  const decrement = () => {
    count.value--;
  };
  
  const reset = () => {
    count.value = 0;
  };
  
  return { count, increment, decrement, reset };
};
vue
<!-- Componente A -->
<script setup>
const { count, increment } = useCounter();
</script>

<template>
  <div>
    <h2>Componente A</h2>
    <p>Contador: {{ count }}</p>
    <button @click="increment">+</button>
  </div>
</template>
vue
<!-- Componente B -->
<script setup>
const { count, decrement } = useCounter();
</script>

<template>
  <div>
    <h2>Componente B</h2>
    <p>Contador: {{ count }}</p>
    <button @click="decrement">-</button>
  </div>
</template>

Estado de Autenticación

ts
// composables/useAuth.ts
export interface User {
  id: number;
  name: string;
  email: string;
  role: 'user' | 'admin';
}

export const useAuth = () => {
  // Estado global del usuario
  const user = useState<User | null>('auth-user', () => null);
  
  // Estado de autenticación
  const isAuthenticated = computed(() => !!user.value);
  const isAdmin = computed(() => user.value?.role === 'admin');
  
  // Acciones
  const login = async (email: string, password: string) => {
    try {
      const response = await $fetch('/api/auth/login', {
        method: 'POST',
        body: { email, password }
      });
      
      user.value = response.user;
      localStorage.setItem('token', response.token);
      
      return { success: true };
    } catch (error) {
      return { success: false, error: error.message };
    }
  };
  
  const logout = () => {
    user.value = null;
    localStorage.removeItem('token');
    navigateTo('/login');
  };
  
  // Cargar usuario al iniciar
  const loadUser = async () => {
    const token = localStorage.getItem('token');
    if (token) {
      try {
        const userData = await $fetch('/api/auth/me', {
          headers: { Authorization: `Bearer ${token}` }
        });
        user.value = userData;
      } catch {
        user.value = null;
      }
    }
  };
  
  return {
    user,
    isAuthenticated,
    isAdmin,
    login,
    logout,
    loadUser
  };
};

Patrones Avanzados con useState

Estado Compartido con Tipos Complejos

ts
// types/todo.ts
export interface Todo {
  id: number;
  title: string;
  completed: boolean;
  createdAt: Date;
}

// composables/useTodos.ts
export const useTodos = () => {
  const todos = useState<Todo[]>('todos', () => []);
  
  const addTodo = (title: string) => {
    const newTodo: Todo = {
      id: Date.now(),
      title,
      completed: false,
      createdAt: new Date()
    };
    
    todos.value = [...todos.value, newTodo];
  };
  
  const removeTodo = (id: number) => {
    todos.value = todos.value.filter(todo => todo.id !== id);
  };
  
  const toggleTodo = (id: number) => {
    todos.value = todos.value.map(todo =>
      todo.id === id ? { ...todo, completed: !todo.completed } : todo
    );
  };
  
  // Computed properties
  const completedTodos = computed(() =>
    todos.value.filter(todo => todo.completed)
  );
  
  const pendingTodos = computed(() =>
    todos.value.filter(todo => !todo.completed)
  );
  
  return {
    todos: readonly(todos),
    completedTodos,
    pendingTodos,
    addTodo,
    removeTodo,
    toggleTodo
  };
};

Estado con Persistencia en LocalStorage

ts
// composables/usePersistentState.ts
export const usePersistentState = <T>(key: string, defaultValue: T) => {
  // Crear estado global
  const state = useState<T>(key, () => {
    // Intentar cargar desde localStorage
    if (process.client) {
      const saved = localStorage.getItem(`nuxt-state-${key}`);
      return saved ? JSON.parse(saved) : defaultValue;
    }
    return defaultValue;
  });
  
  // Guardar cambios en localStorage
  watch(state, (newValue) => {
    if (process.client) {
      localStorage.setItem(`nuxt-state-${key}`, JSON.stringify(newValue));
    }
  }, { deep: true });
  
  // Limpiar localStorage
  const clear = () => {
    state.value = defaultValue;
    if (process.client) {
      localStorage.removeItem(`nuxt-state-${key}`);
    }
  };
  
  return { state, clear };
};

// Uso:
const { state: theme, clear: clearTheme } = usePersistentState<string>(
  'theme',
  'light'
);

Estado de UI/Theme

ts
// composables/useTheme.ts
type Theme = 'light' | 'dark' | 'system';

export const useTheme = () => {
  const theme = useState<Theme>('theme', () => 'system');
  
  const isDark = computed(() => {
    if (theme.value === 'system') {
      return process.client && window.matchMedia('(prefers-color-scheme: dark)').matches;
    }
    return theme.value === 'dark';
  });
  
  const setTheme = (newTheme: Theme) => {
    theme.value = newTheme;
    
    // Aplicar clase al body
    if (process.client) {
      document.documentElement.classList.remove('light', 'dark');
      if (newTheme !== 'system') {
        document.documentElement.classList.add(newTheme);
      }
    }
  };
  
  // Inicializar tema
  onMounted(() => {
    setTheme(theme.value);
  });
  
  return { theme, isDark, setTheme };
};

Gestión de Estado para Features Completas

E-commerce: Carrito de Compras

ts
// composables/useCart.ts
export interface CartItem {
  id: number;
  productId: number;
  name: string;
  price: number;
  quantity: number;
  image: string;
}

export const useCart = () => {
  // Estado global del carrito
  const items = useState<CartItem[]>('cart-items', () => []);
  
  // Computed properties
  const totalItems = computed(() =>
    items.value.reduce((sum, item) => sum + item.quantity, 0)
  );
  
  const totalPrice = computed(() =>
    items.value.reduce((sum, item) => sum + (item.price * item.quantity), 0)
  );
  
  const isEmpty = computed(() => items.value.length === 0);
  
  // Acciones
  const addItem = (product: Omit<CartItem, 'id' | 'quantity'>, quantity = 1) => {
    const existingItem = items.value.find(item => item.productId === product.productId);
    
    if (existingItem) {
      existingItem.quantity += quantity;
    } else {
      const newItem: CartItem = {
        ...product,
        id: Date.now(),
        quantity
      };
      items.value.push(newItem);
    }
  };
  
  const updateQuantity = (productId: number, quantity: number) => {
    const item = items.value.find(item => item.productId === productId);
    if (item) {
      if (quantity <= 0) {
        removeItem(productId);
      } else {
        item.quantity = quantity;
      }
    }
  };
  
  const removeItem = (productId: number) => {
    items.value = items.value.filter(item => item.productId !== productId);
  };
  
  const clearCart = () => {
    items.value = [];
  };
  
  // Persistencia
  const saveToStorage = () => {
    if (process.client) {
      localStorage.setItem('cart', JSON.stringify(items.value));
    }
  };
  
  const loadFromStorage = () => {
    if (process.client) {
      const saved = localStorage.getItem('cart');
      if (saved) {
        items.value = JSON.parse(saved);
      }
    }
  };
  
  // Inicializar
  onMounted(loadFromStorage);
  watch(items, saveToStorage, { deep: true });
  
  return {
    items: readonly(items),
    totalItems,
    totalPrice,
    isEmpty,
    addItem,
    updateQuantity,
    removeItem,
    clearCart
  };
};

Dashboard: Estado Complejo

ts
// composables/useDashboard.ts
export const useDashboard = () => {
  // Estado principal
  const isLoading = useState('dashboard-loading', () => false);
  const lastUpdated = useState<Date | null>('dashboard-last-updated', () => null);
  const filters = useState<DashboardFilters>('dashboard-filters', () => ({
    dateRange: 'week',
    category: 'all',
    sortBy: 'date'
  }));
  
  // Datos del dashboard
  const stats = useState<DashboardStats>('dashboard-stats', () => ({
    visitors: 0,
    sales: 0,
    conversion: 0,
    revenue: 0
  }));
  
  const charts = useState<ChartData[]>('dashboard-charts', () => []);
  const recentActivities = useState<Activity[]>('dashboard-activities', () => []);
  
  // Acciones
  const fetchDashboardData = async (forceRefresh = false) => {
    isLoading.value = true;
    
    try {
      const [statsData, chartsData, activitiesData] = await Promise.all([
        $fetch('/api/dashboard/stats'),
        $fetch('/api/dashboard/charts'),
        $fetch('/api/dashboard/activities')
      ]);
      
      stats.value = statsData;
      charts.value = chartsData;
      recentActivities.value = activitiesData;
      lastUpdated.value = new Date();
    } catch (error) {
      console.error('Error fetching dashboard:', error);
    } finally {
      isLoading.value = false;
    }
  };
  
  const updateFilters = (newFilters: Partial<DashboardFilters>) => {
    filters.value = { ...filters.value, ...newFilters };
    fetchDashboardData(true);
  };
  
  // Computed
  const formattedLastUpdated = computed(() => {
    if (!lastUpdated.value) return 'Nunca';
    return lastUpdated.value.toLocaleTimeString();
  });
  
  // Inicialización
  onMounted(() => {
    if (!lastUpdated.value) {
      fetchDashboardData();
    }
  });
  
  return {
    isLoading: readonly(isLoading),
    filters: readonly(filters),
    stats: readonly(stats),
    charts: readonly(charts),
    recentActivities: readonly(recentActivities),
    formattedLastUpdated,
    fetchDashboardData,
    updateFilters
  };
};

Mejores Prácticas con useState

✅ HACER:

ts
// ✅ Keys únicas y descriptivas
useState('app-theme');
useState('user-cart-items');
useState('dashboard-stats');

// ✅ Tipado fuerte
useState<User | null>('auth-user');
useState<Todo[]>('todos', () => []);

// ✅ Readonly para estados públicos
const todos = readonly(useState('todos'));

❌ NO HACER:

ts
// ❌ Keys genéricas
useState('data');
useState('state');

// ❌ Mutación directa sin funciones
// En lugar de esto:
state.value.property = 'new'; // ❌

// Mejor:
const updateProperty = (value) => {
  state.value = { ...state.value, property: value };
}; // ✅

// ❌ Estados gigantes
// Mejor separar en múltiples states

Patrón Factory para Estados Dinámicos

ts
// composables/useStateFactory.ts
export const useStateFactory = <T>(prefix: string) => {
  const states = new Map<string, Ref<T>>();
  
  const getState = (key: string, defaultValue: T) => {
    const fullKey = `${prefix}-${key}`;
    
    if (!states.has(fullKey)) {
      states.set(fullKey, useState<T>(fullKey, () => defaultValue));
    }
    
    return states.get(fullKey)!;
  };
  
  const clearState = (key: string) => {
    const fullKey = `${prefix}-${key}`;
    states.delete(fullKey);
    // Nota: useState no puede ser eliminado, pero podemos limpiar su valor
  };
  
  return { getState, clearState };
};

// Uso:
const modalFactory = useStateFactory<boolean>('modal');
const isLoginModalOpen = modalFactory.getState('login', false);
const isCartModalOpen = modalFactory.getState('cart', false);

Testing con useState

ts
// tests/composables/useCounter.test.ts
import { describe, it, expect, beforeEach } from 'vitest';
import { setActivePinia, createPinia } from 'pinia';
import { useCounter } from '~/composables/useCounter';

describe('useCounter', () => {
  beforeEach(() => {
    // Limpiar estado global antes de cada test
    useState('counter').value = 0;
  });
  
  it('inicializa en 0', () => {
    const { count } = useCounter();
    expect(count.value).toBe(0);
  });
  
  it('incrementa el contador', () => {
    const { count, increment } = useCounter();
    increment();
    expect(count.value).toBe(1);
  });
});

Migración desde Pinia a useState

Pinia Store:

ts
// stores/user.ts
export const useUserStore = defineStore('user', {
  state: () => ({
    user: null as User | null,
    token: ''
  }),
  actions: {
    async login(credentials) { /* ... */ },
    logout() { /* ... */ }
  }
});

Equivalente con useState:

ts
// composables/useAuth.ts
export const useAuth = () => {
  const user = useState<User | null>('auth-user');
  const token = useState<string>('auth-token');
  
  const login = async (credentials) => {
    // ... lógica de login
  };
  
  const logout = () => {
    // ... lógica de logout
  };
  
  return { user, token, login, logout };
};

Conclusión

useState es tu herramienta todo-en-uno para:

  • ✅ Estado global simple sin overhead
  • ✅ Persistencia automática SSR/CSR
  • ✅ Integración nativa con Nuxt
  • ✅ Alternativa ligera a Pinia/Vuex