Destino Dev

Blog

Hydration parcial no Vue 3: otimizar SSR com Pinia, Suspense e lazy components

Por John Pires · 12 de março de 2026

Hydration parcial no Vue 3: otimizar SSR com Pinia, Suspense e lazy components

Contexto e limitações da abordagem convencional

O que a renderização tradicional faz

Server-Side Rendering (SSR) em Vue 3 gera HTML no servidor usando renderToString e envia markup já pronto ao cliente. No cliente, a aplicação faz hydration — o runtime do Vue reconstrói a árvore de componentes em memória e reconcilia com o DOM gerado no servidor. Esse processo reativa tudo: listeners, reatividade, lifecycle hooks. Para páginas grandes e ricas em interatividade, a hidratação completa aumenta o tempo para interatividade (TTI) e consome CPU do cliente antes que o usuário possa usar a página.

Por que a hidratação completa é custosa

A hidratação precisa recriar VNodes, associar listeners, executar setup(), executar watchEffect e possivelmente refazer fetches de dados se não houver estratégia de transferência de estado. Em dispositivos móveis e em páginas com muitas rotas e widgets, isso cria um pico de CPU e memória. Além do tempo de execução, há custo de bundle: todos os componentes interativos precisam estar no bundle inicial ou em chunks críticos, aumentando o download inicial.

Limitações práticas

As abordagens convencionais não separam o que precisa interagir imediatamente do que pode permanecer estático. Você obtém boa indexabilidade e tempo até primeiro paint (FP), mas perde em interatividade. Ferramentas como Nuxt têm estratégias de lazy, mas ainda assim hidratar tudo costuma ser o padrão. É aí que entra a ideia de partial hydration ou islands architecture: manter a maior parte do DOM estático e hidratar apenas componentes interativos.

Quando partial hydration realmente importa

Projetos com páginas longas, dashboards com widgets independentes, e-commerce com muitos blocos estáticos (listagens, descrições) e poucos pontos interativos (carrinho, filtros avançados) ganham mais com partial hydration. Em landing pages ricas em conteúdo estático, a economia de CPU e o menor TTI são perceptíveis.

Deep dive na implementação interna

Como Vue 3 realiza a hydration

Vue usa createSSRApp para construir a árvore do lado do cliente de maneira compatível com o HTML gerado pelo servidor. A função hydrate vincula listeners aos nós existentes em vez de recriá-los do zero. A reconciliação acontece entre a árvore virtual e o DOM real; mismatchs geram warnings e podem substituir subárvores inteiras.

Por que não existe partial hydration nativa

Partial hydration exige que você trate múltiplas árvores de componentes e possivelmente múltiplos runtimes (ou múltiplas invocações de createApp/createSSRApp) na mesma página. Vue 3 permite isso tecnicamente: você pode criar várias aplicações e montar cada uma em um container separado. Mas isso tem implicações — você precisa gerenciar estado compartilhado, transferência de dados do servidor para cliente e evitar duplicação de runtime.

Interações entre Pinia e SSR

Pinia suporta SSR, mas a transferência do estado exige serializar o estado do store no HTML e re-hidratar no cliente antes de montar componentes interativos. Se você monta várias aplicações (islands) e cada uma cria seu próprio Pinia, precisa sincronizar o estado inicial ou centralizá-lo num único Pinia reutilizável por todas as islands antes do mount.

Como Suspense e defineAsyncComponent entram na jogada

Suspense facilita lazy loading de componentes com placeholders — útil para decompor o page bundle em chunks menores. Ao combinar Suspense com islands você pode deixar o servidor renderizar um fallback estático e, no cliente, carregar o componente interativo apenas quando necessário (por visibilidade, interação ou tempo ocioso).

Solução otimizada com código

Visão geral da estratégia

Abordagem: servidor gera HTML completo; marca pontos interativos com atributos data-hydrate="nome" e inclui JSON com estado inicial do Pinia. No cliente, um script detecta esses containers, cria um Pinia global, injeta o estado inicial e monta apenas os componentes marcados, usando defineAsyncComponent + Suspense para lazy-loading. Componentes não marcados permanecem estáticos e não geram hidratação.

Exemplo: store Pinia (stores/useCart.js)

Este código define um store de exemplo para carrinho. No servidor o store é preenchido; o estado é serializado e enviado ao cliente. No cliente, fazemos pinia.state.value = initialState antes de montar os islands para reusar o estado.

// stores/useCart.js
import { defineStore } from 'pinia'
import { ref } from 'vue'

export const useCart = defineStore('cart', () => {
  const items = ref([])
  const add = (p) => items.value.push(p)
  const remove = (id) => {
    items.value = items.value.filter(i => i.id !== id)
  }
  const total = () => items.value.reduce((s, i) => s + i.price * i.qty, 0)
  return { items, add, remove, total }
})

O que faz: declara um store reativo para carrinho. Depois do bloco: o store pode ter seu estado serializado no servidor e re-hidratado no cliente.

Exemplo: componente Cart.vue (lazy)

Componente interativo que será carregado sob demanda. Ele usa o store e expõe UI de adição/remoção. No servidor podemos renderizar uma versão leve (ou placeholder) enquanto o cliente carrega a versão completa.

// components/Cart.vue
<template>
  <div class="cart">
    <h3>Carrinho</h3>
    <ul>
      <li v-for="item in items" :key="item.id">{{ item.name }} x{{ item.qty }} - R$ {{ item.price }}</li>
    </ul>
    <div>Total: R$ {{ total() }}</div>
  </div>
</template>

<script setup>
import { useCart } from '@/stores/useCart'
const cart = useCart()
const { items, total, remove } = cart
// setup() manipula o store re-hidratado pelo cliente
</script>

O que faz: componente que consome Pinia. Depois do bloco: quando montado no cliente, ele refletirá o estado re-hidratado; antes disso, o servidor pode ter renderizado markup estático.

Exemplo: server entry (server.js) - renderizando e serializando estado

Server-side: criamos a app, preenchemos stores, renderizamos para string e injetamos um script com estado serializado. Atenção: use uma serialização segura (ex.: safe-json-stringify ou replacer para XSS).

// server.js (express + @vue/server-renderer)
import express from 'express'
import { renderToString } from '@vue/server-renderer'
import { createSSRApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue' // root App que contém placeholders
import { useCart } from './stores/useCart'

const server = express()

server.get('/', async (req, res) => {
  const app = createSSRApp(App)
  const pinia = createPinia()
  app.use(pinia)

  // prefetch server-side: popular o store
  const cart = useCart(pinia)
  cart.items = [{ id: 1, name: 'Camisa', price: 79.9, qty: 2 }]

  const html = await renderToString(app)
  const initialState = JSON.stringify(pinia.state.value).replace(/${html}
  )
})

server.listen(3000)

O que faz: popula o store no servidor e inclui o estado inicial no HTML. Depois do bloco: o cliente terá acesso a window.__INITIAL_PINIA_STATE__ para re-hidratar o Pinia antes de montar islands.

Exemplo: entry-client.js — montagem parcial (islands)

Script client que encontra containers com data-hydrate e monta componentes especificamente. Aqui usamos defineAsyncComponent para carregar o Cart apenas quando necessário (ex.: ao click ou quando o elemento está visível).

// entry-client.js
import { createApp, defineAsyncComponent } from 'vue'
import { createPinia } from 'pinia'
import { useCart } from './stores/useCart'

const initialState = window.__INITIAL_PINIA_STATE__ || {}
const pinia = createPinia()

// injeta state inicial no pinia antes de montar qualquer island
pinia.state.value = initialState

// registry: map name -> component factory
const registry = {
  cart: () => import('./components/Cart.vue')
}

function mountIsland(el) {
  const name = el.dataset.hydrate
  if (!name || !registry[name]) return

  const asyncComp = defineAsyncComponent(registry[name])
  const app = createApp({
    template: `` ,
    components: { Suspense: (await import('vue')).Suspense },
    data: () => ({ comp: asyncComp })
  })
  app.use(pinia)
  app.mount(el)
}

// montar imediatamente, ou usar intersection observer / evento
document.querySelectorAll('[data-hydrate]').forEach(el => mountIsland(el))

O que faz: cria um Pinia único, injeta estado inicial e monta apenas os islands registrados. Depois do bloco: apenas os componentes marcados são interativos; o resto da página permanece estático.

Dicas para transferir estado de forma segura

Use JSON.stringify + replace de caractere '<' para evitar XSS ao injetar estado. Para estruturas complexas, serialize apenas o que é necessário.

Outras práticas: não inclua funções no estado serializado; normalize apenas dados primitivos e arrays/objetos simples.

Análise de complexidade e trade-offs

Benefícios

  • Menor TTI: menos CPU gasto hidratando componentes que não são usados imediatamente.

  • Menor bundle crítico: lazy loading reduz código inicial.

  • Melhor UX em dispositivos fracos: interatividade localizada.

Esses ganhos são particularmente perceptíveis em páginas com muitos widgets independentes ou longas páginas estáticas com poucos pontos interativos.

Custos e complexidades

  • Complexidade de build e runtime: múltiplas entradas, chunking e roteamento de componentes lazy.

  • Overhead na coordenação do estado: precisa de estratégia para compartilhar Pinia global ou sincronizar estados entre islands.

  • Possível duplicação de runtime se não otimizado (múltiplas invocações de createApp podem aumentar footprint).

Esses custos são gerenciáveis, mas exigem disciplina no time (patterns, pequenas bibliotecas utilitárias para mounting, registro de islands etc.).

Complexidade temporal e espacial

Tempo: reduz TTI mas pode aumentar latência ao carregar um island interativo pela primeira vez (request adicional para chunk). Espaço: reduz bundle crítico, mas número de requests aumenta; use HTTP/2 ou bundlers otimizados para mitigar latência de many small files.

Métricas para avaliar melhoria

  1. Time to Interactive (TTI)

  2. First Input Delay (FID)

  3. CPU Main-thread utilization durante hydration

  4. Bundle size do initial payload

Meça antes/depois com Lighthouse e trace de performance para validar ganhos.

Erros comuns, armadilhas e dicas práticas

Erro: reinstanciar stores e perder estado

Cenário: cada island cria seu próprio Pinia isolado. Resultado: duplicação de estado e perda da versão inicial. Solução: criar um Pinia compartilhado no cliente e injetar antes de montar islands.

Erro: mismatch de HTML e warnings de hydration

Se o servidor renderiza uma árvore e o cliente monta a mesma árvore por createApp ao invés de hydrate, você substituirá o DOM gerado pelo servidor — o que quebra a ideia de hydration. Em partial hydration, aceitamos essa substituição só para islands; garanta que placeholders sejam coerentes e que a substituição não cause flash de conteúdo indesejado.

Erro: XSS ao injetar estado serializado

Serializar estado sem escapar '<' permite injeção de scripts. Sempre escape e serialize apenas o necessário. Use alternativas seguras de serialização quando o payload puder conter strings arbitrárias.

Dicas práticas

  • Registre islands explicitamente e mantenha um registry para evitar montagem acidental.

  • Use IntersectionObserver para montar islands quando próximos à viewport (reduz downloads iniciais).

  • Prefetch chunks críticos em background após TTI usando <link rel="preload" as="script" href="..."> ou requestIdleCallback.

Quando NAO usar essa abordagem

Aplicações altamente interativas por toda a página

Se a maioria dos componentes na página precisa ser reativa e recebe inputs constantes, partial hydration traz pouco benefício — a sobrecarga de gerenciar islands supera ganhos porque você acabará hidratando quase tudo.

Sistemas com estado complexo e sincronização em tempo real

Aplicações que dependem de WebSockets com estado compartilhado muito dinâmico podem sofrer com a complexidade extra de sincronizar multiples islands. Nesses casos, uma única app hidratada pode ser mais simples e robusta.

Quando a infra não suporta muitos requests

Se sua infra não tem HTTP/2/3 ou apresenta alta latência por request, fragmentar em muitos chunks pequenos vai penalizar a experiência. Avalie trade-offs de muitos pequenos arquivos versus poucos arquivos maiores.

Conclusão prática

Partial hydration em Vue 3 é uma técnica pragmática para reduzir TTI e CPU no cliente, especialmente em páginas com conteúdo majoritariamente estático e pontos de interatividade bem delimitados. A combinação de Pinia para transferência de estado, Suspense/defineAsyncComponent para lazy-loading e um registry de islands cria um padrão replicável e eficiente. No entanto, a técnica aumenta a complexidade do pipeline e exige disciplina na serialização de estado, no registro de islands e na orquestração de mounts.

Resumo rápido de ação:

  • Serializar e injetar estado do Pinia no servidor.

  • Criar um Pinia compartilhado no cliente e re-hidratar antes de montar islands.

  • Marcar pontos interativos com data-hydrate e montar componentes apenas nesses containers.

  • Usar Suspense + defineAsyncComponent para carregar chunks sob demanda e IntersectionObserver para montar on-demand.

Implementar partial hydration requer testes cuidadosos e métricas para validar ganhos. Comece por isolar 1–2 widgets com maior impacto no TTI e evolua o padrão conforme os resultados.

Artigo inclui exemplos práticos de store, componente, entrada servidor e entrada cliente. Adapte o registry e o fluxo de serialização para sua stack (Vite, Rollup, Nuxt) e priorize segurança na transferência de estado.

Assuntos: Vue.js, pinia, ssr, partial-hydration, islands, Performance

Leia também