Understand Nuxt Hydration by Building useState From Scratch

Learn how useState works in Nuxt by building your own version from scratch. This article explores state management and hydration in Nuxt, helping you understand the inner workings of this essential composable.

Michael Thiessen
Nuxt 3

Mastering Nuxt 3 course is here!

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

Click here to view the Nuxt 3 course

Do you know how useState in Nuxt works?

You probably use this composable all the time, but have you ever taken the time to understand how it works?

In this article, we'll build our own simple version of useState, step by step.

You’ll gain insight into the fundamentals of state management and hydration in Nuxt.

By the end, you’ll be among the few who can break down and recreate this foundational composable.

Let’s dive into the inner workings of useState and see what it takes to make it yourself.

Scaffolding out the composable

Okay, so we'll start with just scaffolding out a basic state management composable as our starting point:

import { ref, type Ref } from 'vue';

// Super basic, just returning a ref
export function useMyState<T>(key: string): Ref<T> {
  const state = ref<T>(null as T);
  return state as Ref<T>;
}

Here, we're not even using the key parameter, but we will in a second.

We're just creating a ref, and then using TypeScript generics to type it correctly as T. This lets us use the composable like this:

const myState = useMyState<number>('count');
myState.value = 34;

Adding in initialization

Next, we'll add in the ability to pass in a init function so we can initialize the value:

import { ref, type Ref } from 'vue';

// Now we can use a function to initialize the state
export function useMyState<T>(
  key: string,
  init?: () => T
): Ref<T> {
  const initialValue = init ? init() : null;
  const state = ref<T>(initialValue as T);
  return state as Ref<T>;
}

This init parameter is optional. If we have it, we run it and use the returned value to initialize our state ref.

You might be wondering why we use a function instead of a direct value, and this has two answers:

  1. This is what useState does, and we're re-building a version of it
  2. Getters like this allow us to do lazy evaluation, which gives us more flexibility in what this initialized value can be

This new version can be used like this:

const myState = useMyState<number>('count', () => 0);
console.log(myState.value) // 0

Adding Basic SSR Support

Now that we've laid the groundwork, it's time to add in the interesting stuff: support for hydration! First, we'll tackle saving the server generated value and sending it over to the client.

With Nuxt, this is actually pretty straightforward, because of how extensible Nuxt is:

import { ref, type Ref } from 'vue';

// Add in hydration support using payload and nuxtApp
// But it only supports primitives and plain objects
export function useMyState<T>(
  key: string,
  init?: () => T
): Ref<T> {
  // Already scoped per request
  const nuxtApp = useNuxtApp();

  // Save initialized value to payload
  const initialValue = init ? init() : null;
  if (import.meta.server) {
    if (!nuxtApp.payload.state[key]) {
      nuxtApp.payload.state[key] = initialValue;
    }
  }

  const state = ref<T>(initialValue as T);
  return state as Ref<T>;
}

On the server, we need to initialize the value and then save it somewhere. The place we'll save it is the Nuxt payload, a special object that gets sent to the client alongside the HTML of the page.

First, we get the current Nuxt instance with useNuxtApp:

const nuxtApp = useNuxtApp();

Then, if we're on the server, we save the initialized value to this payload object:

// Initialize the value
const initialValue = init ? init() : null;

// Check if we're on the server
if (import.meta.server) {
  // Save the value if we haven't yet
  if (!nuxtApp.payload.state[key]) {
    nuxtApp.payload.state[key] = initialValue;
  }
}

We have this check in there to make sure that the value hasn't already been initialized, which is important.

One of the features of useState (and useMyState) is that it can be used as a global state management tool. You can use it to access the same value in many different components or composables:

// ComponentA initializes to zero
const myState = useMyState<number>('count', () => 10);

// ComponentB just reads the value without overriding
const myState = useMyState<number>('count');

Once we're in the client, we have to then hydrate this value back from the payload back into the useMyState composable so it can be used in our component.

Hydrating the value back into the composable

We've got the first half of the hydration equation, now we just need to get the state back into our useMyState composable:

import { ref, type Ref } from 'vue';

// Add in hydration support using payload and nuxtApp
// But it only supports primitives and plain objects
export function useMyState<T>(
  key: string,
  init?: () => T
): Ref<T> {
  // Already scoped per request
  const nuxtApp = useNuxtApp();

  // Save initialized value to payload
  const initialValue = init ? init() : null;
  if (import.meta.server) {
    if (!nuxtApp.payload.state[key]) {
      nuxtApp.payload.state[key] = initialValue;
    }
  }

  const state = ref<T>(initialValue as T);

  // Restore state on client-side
  if (import.meta.client) {
    if (nuxtApp.payload.state[key] !== undefined) {
      state.value = nuxtApp.payload.state[key];
    }
  }

  return state as Ref<T>;
}

Here, we're essentially just going in the reverse direction.

We get the current Nuxt instance, which gives us access to the payload, just like before. But when we're on the client, we read the value from the payload and use it to set our ref:

state.value = nuxtApp.payload.state[key];

Now we have full hydration support!

Creating isomorphic composables

This one might take a bit to wrap your brain around, because we’ve created an isomorphic composable — it works on both the server and the client.

We need to behave nearly the same, but have only a slight tweak in logic depending on where it's run. To do this, we're using the import.meta.server and import.meta.client values to check our environment.

Did you spot the performance bug?

There's a slight performance bug in this implementation, which might take a little refactoring to fix.

If we pass in a init function, it will always be called, even if we're on the client and we're relying on the hydrated value.

Of course, this is a simplified version, so there are other edge cases that aren't covered here.

Wrapping Up

We've now built a simple version of useState that supports SSR and hydration.

This composable is a foundational piece of Nuxt, and now you have a deeper understanding of how it works under the hood.

If you enjoyed this article, please consider sharing it with others who might find it helpful!

Michael Thiessen
Michael is a passionate full time Vue.js and Nuxt.js educator. His weekly newsletter is sent to over 11,000 Vue developers, and he has written over a hundred articles for his blog and VueSchool.

Follow MasteringNuxt on