Understanding useState in Nuxt: The Simple Way to Share State

Need a simple shared state management solution for your Nuxt app? In this article, explore `useState` : Nuxt’s built-in composable for shared reactive data that works seamlessly with SSR.

Charles Allotey
Nuxt 3

The Mastering Nuxt FullStack Unleashed Course is here!

Get notified when we release new tutorials, lessons, and other expert Nuxt content.

Click here to view course

State management is one of those things that can make or break your app's architecture. Get it right, and your components communicate smoothly. Get it wrong, and you're passing props through five levels just to share a single value.

Nuxt gives you useState as a simple solution for managing shared state across your application, and it works seamlessly with server-side rendering. No configuration needed, no complex setup - it just works.

Here's what a simple shared counter looks like:

<script setup lang="ts">
const counter = useState('counter', () => 0)
</script>

<template>
<div>
  Counter: {{ counter }}
  <button @click="counter++">+</button>
  <button @click="counter--">-</button>
</div>
</template>

Any component in your app that uses useState('counter') will share the same reactive state. Update it in one place, and it updates everywhere.

Let's explore how to use this simple concept for state management effectively in your Nuxt applications.

The Key Difference: useState vs ref for Global State

In Vue.js, you'd can use ref() to create reactive global state. But useState is specifically designed for Nuxt, handles server-side rendering properly, and eliminates performance and security issues cased by ref for global state.

The value also persists after server-side rendering and syncs correctly during client-side hydration. This means no mismatches between what the server rendered and what the client expects.

The Critical Rule to Remember about Ref and Global State

Here's something that trips up a lot of developers when they first start:

Don't create state outside of components or composables with ref!

// ❌ This will cause problems
// can leak sensitive info across requests
// or result in performance issues!
export const myState = ref({})
export useMyState = ()=> myState;

This pattern shares state across all server requests, which can leak data between users and cause memory issues.

Instead, use useState:

// ✅ This is the right way
// app/composables/useMyState.ts
export const useMyState = () => useState('myState', () => ({}))

Now each request gets its own isolated state, and you can still share it across components within that request.

Loading Data Into State

Most of the time, you'll need to initialize global state with data from an API. You can use callOnce to ensure your data loads exactly once, even if multiple components need it:

// app/composbles/useConfig.ts
export const useConfig = () => {
        const config = useState('config', () => ({}));
        await callOnce(async () => {
          config.value = await $fetch('/api/config')
        })
        return config;
}

This is really useful. Without callOnce, every component that needs this data would trigger its own API call. With it, the first component loads the data and everyone else just uses it.

Building a Theme Switcher

Let's build something practical - a dark mode toggle that persists across page loads and works with SSR.

composables/useTheme.ts

type Theme = 'light' | 'dark';

export const useTheme = () => {
  console.log('running composable');
  // get the saved state
  const theme = useState<Theme>('theme', () => 'light');

  watch(theme, (newTheme) => {
    localStorage.setItem('theme', newTheme);
    document.documentElement.classList.toggle('dark', newTheme === 'dark');
  });

  onMounted(() => {
    const saved = localStorage.getItem('theme');
    if (saved) {
      theme.value = saved as Theme;
    } else {
      theme.value = window.matchMedia('(prefers-color-scheme: dark)').matches
        ? 'dark'
        : 'light';
    }
  });

  return theme;
};

Now you can use it anywhere in your app:

<script setup lang="ts">
const theme = useTheme()
</script>

<template>
<button @click="theme = theme === 'light' ? 'dark' : 'light'">
  Toggle Theme
</button>
</template>

The state is shared across your entire app, persists in localStorage, and respects system preferences on first load.

You can see a demo of the composable here.

Creating Reusable State Composables

You can also create composables for any shared business logic state in your app. Here's a shopping cart example:

composables/useCart.ts

export const useCart = () => {
  const items = useState<Product[]>('cart', () => [])

  const addItem = (product: Product) => {
    items.value.push(product)
  }

  const removeItem = (id: string) => {
    items.value = items.value.filter(item => item.id !== id)
  }

  const total = computed(() => {
    return items.value.reduce((sum, item) => sum + item.price, 0)
  })

  return {
    items,
    addItem,
    removeItem,
    total
  }
}

Use it in any component:

<script setup lang="ts">
const { items, addItem, total } = useCart()
</script>

<template>
<div>
  <p>Items in cart: {{ items.length }}</p>
  <p>Total: ${{ total }}</p>
</div>
</template>

When to Use Pinia Instead

For simple shared state - user preferences, UI state, basic data - useState is perfect. But when you need more structure, Pinia is the way to go.

Use Pinia when you have:

  • Complex business logic that needs to be organized
  • Multiple related actions that modify state
  • A larger application with lots of global state

Install it with:

npx nuxt module add pinia

Then create a store:

stores/user.ts

export const useUserStore = defineStore('user', {
  state: () => ({
    profile: null,
    preferences: {}
  }),

  actions: {
    async fetchProfile() {
      this.profile = await $fetch('/api/user/profile')
    },

    async updatePreferences(prefs) {
      this.preferences = { ...this.preferences, ...prefs }
      await $fetch('/api/user/preferences', {
        method: 'PUT',
        body: this.preferences
      })
    }
  }
})

Both useState and Pinia work great with SSR and can be used together in the same app. Start with useState for simple cases, and reach for Pinia when you need more power.

Quick Tips

**Clear state when needed:**

import { clearNuxtState } from '#app'

// Clear specific state
clearNuxtState('user')

// Clear multiple states
clearNuxtState(['cart', 'preferences'])

Initialize state in app.vue: If you have global data that every page needs, load it in app.vue so it's available immediately:

<script setup lang="ts">
const settings = useState('settings')

await callOnce(async () => {
  settings.value = await $fetch('/api/settings')
})
</script>

Wrapping Up

State management in Nuxt is straightforward. Use useState for shared reactive state that works with SSR. Wrap it in composables for reusability. And when your needs grow more complex, Pinia is there as a natural next step.

The key is keeping it simple until you actually need something more complex. Start with useState, and you'll be surprised how far it can take you.


Looking to dive deeper into state management in Nuxt and more? MasteringNuxt is offering hands-on tutorials on learning everything Nuxt so you can level up your Nuxt skills to build fullstack web applications.

Charles Allotey
Charles is a Frontend Developer at Vueschool. Has a passion for building great experiences and products using Vue.js and Nuxt.

Follow MasteringNuxt on