useState
Effective state management is crucial for maintaining the consistent and reliable data flow within an application.
Nuxt 3 provides the useState
composable as a convenient out-of-the-box solution for state management. For Nuxt developers, mastering useState
and the hydration process can help optimize performance and scalability.
In this article, we will delve deep into these concepts, demonstrating how useState
can effectively replace ref
in many scenarios and prevent Nuxt hydration mismatches that can lead to unexpected behavior and errors.
Nuxt offers two primary rendering modes:
Universal mode is the default mode for Nuxt and enables server-side rendering (SSR). In Nuxt, SSR involves rendering a web page’s initial HTML on the server, sending it to the client, and then adding event listeners and states to make it interactive.
Client-side rendering (CSR) mode renders the entire page on the client side. The browser downloads and executes JavaScript code, generating the HTML elements.
In this simple example, the Nuxt page displays a list of stock symbols and their corresponding prices:
<script setup lang="ts"> import { ref } from 'vue' const stocks = ref([ { symbol: 'AAPL', price: 150 }, { symbol: 'GOOGL', price: 2000 }, { symbol: 'AMZN', price: 3500 }, ]) </script> <template> <div> <h2>Stock Prices</h2> <ul> <li v-for="stock in stocks" :key="stock.symbol"> {{ stock.symbol }}: {{ stock.price }} </li> </ul> </div> </template>
First, let’s have a look at how the page will be loaded in CSR mode.
In CSR mode, the initial HTML is sent to the browser, and the client-side JavaScript takes over to render the application. The initial HTML will look like this:
<html data-capo=""> <head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1"> ... </head> <body> <div id="__nuxt"></div> <div id="teleports"></div> <script type="application/json" data-nuxt-logs="nuxt-app">[[]]</script> <script type="application/json" data-nuxt-data="nuxt-app" data-ssr="false" id="__NUXT_DATA__">[{"serverRendered":1},false]</script> <script>window.__NUXT__ = {}; window.__NUXT__.config = { public: {}, app: { baseURL: "/", buildId: "dev", buildAssetsDir: "/_nuxt/", cdnURL: "" } }</script> </body> </html>
The above HTML does not include the application state but contains a root element, such as <div id="__nuxt">.
The browser will download the JavaScript files, then the client-side JavaScript mounts the Vue application onto the element, initializes the state, and renders the full HTML in the browser.
However, for large applications using CSR, the initial load time can be significantly slower. Search engines may struggle to crawl and index content in CSR-rendered pages, potentially resulting in lower search result rankings. To improve user experience and optimize SEO, we can utilize SSR.
When using the default universal mode, which enables SSR, the server renders the HTML with the stock prices before sending it to the client:
<!DOCTYPE html> <html data-capo=""> <head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1"> // ... removed for simplicity </head> <body> <div id="__nuxt"> <!--[--> <div data-v-inspector="pages/stock1.vue:2:5"> <h2 data-v-inspector="pages/stock1.vue:3:7">Stock Prices</h2> <ul data-v-inspector="pages/stock1.vue:4:7"> <!--[--> <li data-v-inspector="pages/stock1.vue:5:9">AAPL: 150</li> <li data-v-inspector="pages/stock1.vue:5:9">GOOGL: 2000</li> <li data-v-inspector="pages/stock1.vue:5:9">AMZN: 3500</li> <!--]--> </ul> </div> <!--]--> </div> <div id="teleports"></div> <script type="application/json" data-nuxt-logs="nuxt-app"> </script> <script type="application/json" data-nuxt-data="nuxt-app" data-ssr="true" id="__NUXT_DATA__"> // ... removed for simplicity </script> </body> </html>
The HTML content above includes the stock prices pre-generated by the server before being sent to the client. Pre-rendering the HTML on the server improves the initial page load time and enhances SEO by allowing search engines to index the rendered HTML easily.
However, SSR is not without its challenges. Hydration mismatch is one of the tricky issues.
Hydration is the client-side process of converting the server-rendered HTML into interactive HTML by attaching JavaScript behavior and initializing application states.
The following sequence diagram illustrates the steps in hydration based on the stock prices example:
A hydration mismatch error occurs when the browser generates a DOM structure that differs from the server-rendered HTML. These mismatches can lead to visual glitches or unexpected behavior, disrupting the user experience.
One of the main causes of hydration mismatch is handling dynamic data using SSR. Let’s look at an example:
<script setup lang="ts"> import { ref } from 'vue' const stocks = ref([ { symbol: 'AAPL', price: generateRandomPrice() }, { symbol: 'GOOGL', price: generateRandomPrice() }, { symbol: 'AMZN', price: generateRandomPrice() }, ]) function generateRandomPrice() { return Math.floor(Math.random() * 900) + 100 } </script> <template> <div> <h2>Stock Prices</h2> <ul> <li v-for="stock in stocks" :key="stock.symbol"> {{ stock.symbol }}: ${{ stock.price }} </li> </ul> </div> </template>
Here, we use the generateRandomPrice()
function to generate a random price for each stock and use ref
to store the stock price. Everything seems fine, right?
However, when running the above page, we notice that the stock prices flicker, and the following warning is shown in the console:
The warning message in the console points to the issue:
— rendered on server: GOOGL: $741 — expected on client: GOOGL: $282.
The stock prices are generated twice! When the page is initially rendered on the server, the stocks
array is created, and the generateRandomPrice()
function is called to generate random stock prices. Once the initial HTML is sent to the client, the client-side JavaScript takes over and reinitializes the stocks
array with a new set of random prices.
This discrepancy between the server-generated and client-generated random prices results in a hydration mismatch.
ref
In Nuxt, ref
is used to create a reactive variable for storing and managing component-level states. While ref
provides a simple way to manage reactive state, it can sometimes lead to Nuxt hydration issues, particularly when used to control the initial rendering of the DOM.
The root cause of the hydration mismatch described above is the use of ref
to store the stock price. Variables created by ref
are not automatically serialized and sent to the client during server-side rendering. As a result, when the client-side JavaScript takes over, it initializes the stocks
array from scratch, leading to a mismatch between the server-rendered and client-side states.
To resolve this issue, we can leverage useState
.
useState
: A hydration-friendly solutionNuxt 3 introduces useState
, a composable that provides a reactive and persistent state across components and requests, making it ideal for managing data that impacts the server-rendered HTML. Unlike ref
, useState
is specifically designed to handle state hydration in Nuxt’s SSR mode. When a page is rendered on the server, the useState
values are serialized and sent to the client. This enables the client-side JavaScript to initialize the state with the values from the server side, avoiding re-running the setup script.
useState
is a composable function with the following type definition. It accepts a unique key and a factory function to initialize the state:
useState<T>(init?: () => T | Ref<T>): Ref<T> useState<T>(key: string, init?: () => T | Ref<T>): Ref<T>
key
is a unique identifier for the state. It ensures the state is uniquely identified and persisted. If not provided, a key is automatically generated based on the file and line number.
init
is a factory function used to define the initial state and is only called once during the first SSR request.
Here is the updated version of the stock price page using useState
:
<script setup lang="ts"> const stocks = useState('stocks', () => [ { symbol: 'AAPL', price: generateRandomPrice() }, { symbol: 'GOOGL', price: generateRandomPrice() }, { symbol: 'AMZN', price: generateRandomPrice() }, ]) function generateRandomPrice() { return Math.floor(Math.random() * 900) + 100 } </script> <template> <div> <h2>Stock Prices</h2> <ul> <li v-for="stock in stocks" :key="stock.symbol"> {{ stock.symbol }}: ${{ stock.price }} </li> </ul> </div> </template>
Here, we use useState
to store the stock price. This ensures the state is created only once on the server side and shared between the server and the client browser, thus preventing the script from running again on the client side.
Note that data within useState
is serialized to JSON during transmission. Therefore, we should avoid non-serializable data types such as classes, functions, or symbols, as including them will cause runtime errors.
With useState
, we can return a reactive state variable. To mutate the state, we can assign a new value to the value
property of the ref
object, as shown in the example below.
function addStock(symbol) { stocks.value = [...stocks.value, { symbol, price: generateRandomPrice()}] }
Nuxt will automatically detect these changes and update any components that depend on this state, triggering re-renders as necessary.
To clear the cached state of useState
, we can use clearNuxtState
.
For example, we can add the following function to the previous stock price page:
const resetState = () => { clearNuxtState('stocks') } ... // in template, add a button <button @click="resetState">Reset</button>
Here we pass in a stocks
key to delete the cached stocks
state. Calling the utility function without keys will invalidate all states.
shallowRef
to improve performanceWhen using useState
with large, complex objects, any change to a deeply nested property triggers re-renders, even if that property isn’t directly used in the template. This can lead to unnecessary computations and performance bottlenecks.
shallowRef
is a function in Vue 3’s reactivity API that, similar to ref
, creates a reactive reference. However, unlike ref
, it only tracks changes to the top-level value of the reference and does not make nested properties deeply reactive.
To initialize a state with shallowRef
, we use the following syntax:
>useState('myState', () => shallowRef({...}))
Let’s apply shallowRef
to our example:
const stocks = useState('stocks', () => shallowRef([ { symbol: 'AAPL', price: generateRandomPrice() }, { symbol: 'GOOGL', price: generateRandomPrice() }, { symbol: 'AMZN', price: generateRandomPrice() }, ]))
Please note that modifying the stocks
array directly won’t trigger a re-render because the array is considered a nested part of the shallowRef
:
// This won't trigger a re-rendering function addStock(symbol: string) { stocks.value.push({ symbol, price: generateRandomPrice() }) } // Assigning a new object to state.value will trigger a re-render because it's a top-level change that shallowRef is tracking function addStock(symbol: string) { stocks.value = [...stocks.value, { symbol, price: generateRandomPrice() }] } >
useState
vs ref
useState
is designed to handle hydration automatically. Unlike ref
, the state managed by useState
persists between page navigations, making it suitable for data that needs to be shared between components or pages. Instead of relying on prop drilling, we can use useState
to share states across the applications.
For example, in the following case, useState
is used to store and retrieve the authentication state, auth
. The middleware relies on this shared state to determine the user’s login status and make redirection decisions:
// assumes that another part of the app (e.g., a login component) is responsible for populating the auth state appropriately. // Code snippet source: https://nuxt.com/docs/api/utils/define-nuxt-route-middleware export default defineNuxtRouteMiddleware((to, from) => { const auth = useState('auth') if (!auth.value.isAuthenticated) { return navigateTo('/login') } if (to.path !== '/dashboard') { return navigateTo('/dashboard') } })
While useState
is an SSR-friendly ref
replacement, ref
can be still useful in some situations. When dealing with a state that is local to a single component and doesn’t require server-side rendering, using ref
can be more performant, as it avoids the overheads involved in useState
‘s global approach.
Here are some useful tools that help us troubleshoot and resolve Nuxt hydration errors.
Nuxt DevTools is an official, powerful suite of visual tools that integrates seamlessly into your development workflow. You can find out how to get started here.
The following screenshot shows the “State” tab, which provides a real-time view of the application’s state. You can see the values of useState
variables and other reactive data, allowing us to track changes, understand how data is being updated, and identify any unexpected behavior:
Nuxt-hydration is a valuable development tool designed to identify and debug hydration issues in Nuxt applications.
Nuxt-hydration helps you identify hydration issues by providing detailed component-level insights and allowing you to view the SSR-rendered HTML.
Below is a popup highlighting the hydration mismatch issue discussed earlier:
While useState
is a convenient option for simple data sharing in Nuxt, we may need to require dedicated state management solutions for more complex applications. A popular choice is Pinia.
Pinia is the officially recommended state management library for Vue, making it a natural fit for Nuxt applications.
Pinia builds on the concepts of useState
but offers a more robust and scalable solution for state management as our application’s complexity increases. Pinia’s API, which closely resembles Vue’s Composition API, makes it easy to learn and use. It promotes a modular approach by encouraging the creation of separate stores for different parts of the application, significantly improving code organization and maintainability.
The choice between Pinia and useState
largely depends on the application’s complexity. For simple use cases, useState
is a solid choice, providing an enhancement over ref
. However, as projects scale, Pinia’s richer features and inherent scalability provide clear advantages.
In this article, we’ve explored Nuxt state management and rendering, diving deep into the nuances of useState
. We’ve seen how useState
is essential for managing state across components and unlike ref
, it effectively addresses Nuxt hydration challenges by design. This makes it the preferred choice for managing state in many Nuxt applications.
Additionally, tools like Nuxt DevTools and nuxt-hydration are invaluable for debugging, and Pinia offers a powerful solution for larger projects.
Understanding these concepts is important for building efficient and scalable Nuxt applications. I hope this article has been helpful! The code examples in the article are available here.
Would you be interested in joining LogRocket's developer community?
Join LogRocket’s Content Advisory Board. You’ll help inform the type of content we create and get access to exclusive meetups, social accreditation, and swag.
Sign up nowExplore the evolution of list components in React Native, from `ScrollView`, `FlatList`, `SectionList`, to the recent `FlashList`.
Explore the benefits of building your own AI agent from scratch using Langbase, BaseUI, and Open AI, in a demo Next.js project.
Demand for faster UI development is skyrocketing. Explore how to use Shadcn and Framer AI to quickly create UI components.
The recent merge of Remix and React Router in React Router v7 provides a full-stack framework for building modern SSR and SSG applications.