현록

Nuxt 3와 4에서 useState와 ref 사용 기준

Nuxt에서 반응형 값을 만들 때 Vue의 ref와 Nuxt의 useState를 모두 사용할 수 있다.
두 함수 모두 Ref를 반환하지만 상태가 살아 있는 범위와 SSR 결과를 브라우저로 전달하는 방식이 다르다.

한 컴포넌트 안에서만 쓰는 값은 ref가 기본 선택이다.
여러 컴포넌트가 같은 값을 공유하거나 서버에서 만든 값을 hydration 뒤에도 유지해야 한다면 useState가 맞다.

이 글은 2026년 8월 현재 Nuxt 3.21과 Nuxt 4.5 문서를 기준으로 한다.
Nuxt 3는 2026년 7월 31일 지원이 종료됐으므로 새 프로젝트라면 Nuxt 4를 기준으로 잡는 편이 안전하다.

ref의 컴포넌트 로컬 상태

ref는 Vue의 반응형 값을 만드는 API다.
컴포넌트의 <script setup>에서 호출하면 각 컴포넌트 인스턴스가 자기 값을 가진다.

<script setup lang="ts">
const isOpen = ref(false)

function toggle() {
  isOpen.value = !isOpen.value
}
</script>

<template>
  <button type="button" @click="toggle">
    {{ isOpen ? '닫기' : '열기' }}
  </button>
</template>

메뉴의 열림 여부, 입력 중인 문자열, 특정 컴포넌트의 탭처럼 다른 곳에서 알 필요가 없는 UI 상태에 잘 맞는다.
컴포넌트가 사라지면 해당 인스턴스의 상태도 함께 정리되므로 수명도 이해하기 쉽다.

같은 composable 안에서 ref를 만들어 반환해도 호출할 때마다 새 ref가 만들어지는 것이 일반적이다.
호출한 모든 컴포넌트가 같은 값을 보기를 기대한다면 별도 공유 구조가 필요하다.

useState의 key 기반 공유

useState는 Nuxt가 제공하는 SSR 친화적인 공유 상태 composable이다.
같은 Nuxt 앱 안에서 같은 key를 사용한 호출은 같은 ref를 돌려준다.

// app/composables/useTheme.ts
export const useTheme = () => {
  return useState<'light' | 'dark'>('theme', () => 'light')
}
<script setup lang="ts">
const theme = useTheme()

function toggleTheme() {
  theme.value = theme.value === 'light' ? 'dark' : 'light'
}
</script>

다른 컴포넌트에서 useTheme()을 다시 호출해도 theme key의 상태를 함께 사용한다.
문자열 key는 앱 전체의 이름표이므로 기능 이름을 포함해 충돌하지 않게 정하는 편이 좋다.

key를 생략하면 Nuxt가 호출 파일과 줄 위치를 바탕으로 key를 만든다.
여러 위치에서 의도적으로 공유할 상태라면 자동 key에 기대지 않고 명시적인 key를 사용하는 편이 코드의 의도를 분명하게 만든다.

SSR과 hydration의 차이

Nuxt의 초기 화면 코드는 서버 렌더링과 브라우저 hydration에서 실행될 수 있다.
실행할 때마다 결과가 달라지는 값을 일반 ref로 만들고 화면에 출력하면 서버 HTML과 브라우저의 첫 값이 달라질 수 있다.

<script setup lang="ts">
const randomNumber = ref(Math.round(Math.random() * 1000))
</script>

<template>
  <p>{{ randomNumber }}</p>
</template>

useState의 초기화 함수로 만든 값은 서버의 Nuxt payload에 들어가 브라우저로 전달된다.
브라우저는 같은 key의 상태를 복원하므로 hydration에서 초기화 함수를 다시 계산한 다른 값을 곧바로 표시하지 않는다.

<script setup lang="ts">
const randomNumber = useState('random-number', () => {
  return Math.round(Math.random() * 1000)
})
</script>

<template>
  <p>{{ randomNumber }}</p>
</template>

그렇다고 SSR에서 사용하는 모든 ref를 useState로 바꿀 필요는 없다.
초깃값이 서버와 브라우저에서 같고 다른 컴포넌트와 공유하지 않는 로컬 상태라면 ref가 더 작은 범위를 정확히 표현한다.

서버에서 조회한 데이터와 요청 상태는 useState에 직접 넣기보다 useFetchuseAsyncData가 맞는 경우가 많다.
초기 조회와 이벤트 요청의 구분은 Nuxt 3와 4에서 useFetch와 $fetch 차이에서 이어서 볼 수 있다.

서버 요청 격리와 직렬화

SSR 서버의 module 최상위에서 ref를 만들어 export하면 하나의 서버 프로세스를 사용하는 여러 요청이 같은 상태를 볼 수 있다.
사용자별 값이 섞이거나 상태가 계속 남아 메모리를 차지할 수 있으므로 전역 ref 패턴은 피해야 한다.

// 피해야 하는 서버 공유 상태
export const currentUser = ref<{ id: string } | null>(null)

// 요청별로 격리되는 Nuxt 상태
export const useCurrentUser = () => {
  return useState<{ id: string } | null>('current-user', () => null)
}

서버에서 useState의 상태는 요청 단위로 격리되고, 클라이언트에서는 현재 Nuxt 앱 안에서 key로 공유된다.
다만 payload로 브라우저에 전달되므로 비밀 키나 서버 전용 정보는 useState에 넣으면 안 된다.

상태 값은 직렬화할 수 있는 데이터로 제한하는 편이 안전하다.
함수, Symbol, class instance처럼 그대로 직렬화할 수 없는 값을 넣으면 오류가 나거나 기대한 형태로 복원되지 않을 수 있다.

API 비밀 값과 공개 값을 나누는 기준은 Nuxt 3와 4에서 runtimeConfig로 환경변수 관리하기에서 정리했다.

초기화와 선택 기준

useState의 초기화 함수는 해당 key가 아직 만들어지지 않았을 때만 기본값을 제공한다.
비동기 데이터가 필요하다면 초기화 함수 자체를 async로 만들기보다 callOnce와 함께 명시적으로 채우는 방식을 검토한다.

<script setup lang="ts">
const settings = useState<{ locale: string }>('settings')

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

큰 객체에서 깊은 반응성이 필요 없다면 Nuxt 4의 useState 초기화 함수에서 shallowRef를 반환해 반응형 비용을 줄일 수 있다.
로그아웃이나 테스트 초기화처럼 저장한 상태를 지울 때는 clearNuxtState를 사용할 수 있다.

Nuxt 4.4 이후에는 clearNuxtState의 reset 동작이 compatibility 설정에 따라 달라질 수 있으므로 설치 버전의 문서를 확인해야 한다.
공유 상태가 많고 action, plugin, 개발 도구 같은 구조가 필요해지면 useState를 계속 늘리기보다 Pinia 같은 store 도입을 검토한다.

선택 기준은 상태의 범위에서 시작한다.
컴포넌트 로컬 UI 상태는 ref, key로 공유하고 SSR payload에 보존할 작은 상태는 useState, 복잡한 도메인 상태는 별도 store가 자연스럽다.

참고 자료

관련 포스트
Nuxt 3와 4에서 useFetch와 $fetch 차이 thumbnail
Nuxt 3와 4에서 useFetch와 $fetch 차이
Nuxt 3와 4에서 useFetch와 $fetch를 선택하는 기준을 정리합니다. SSR payload, hydration 중복 요청, 반응형 상태, 이벤트 기반 요청, 쿠키 전달과 버전 차이를 함께 봅니다.
Nuxt 3와 4에서 runtimeConfig로 환경변수 관리하기 thumbnail
Nuxt 3와 4에서 runtimeConfig로 환경변수 관리하기
Nuxt 3와 Nuxt 4에서 runtimeConfig를 사용해 서버 전용 값과 클라이언트 공개 값을 나누고, NUXT_ 환경변수로 런타임 값을 덮어쓰는 방법을 정리합니다.
Nuxt 무중단 배포 구현기 thumbnail
Nuxt 무중단 배포 구현기
올해 1분기 쯔음 회사에서 처음으로 배포한 Nuxt 프로젝트가 있었다. 서비스가 운영 단계로 들어서면서 소스 수정이 매우 잦아졌고, 배포 과정마다 사용자는 정상적으로 서비스를 사용하기 어려운 상황을 마주했다. 원인을 분석한 결과, build 과정 초기에 .nuxt 폴더에 있는 번들파일을 제거하기 때문에 사용자들이 번들파일을 내려받지 못하여 벌어진 현상이었다.
nested router의 기본 route 지정해주기 thumbnail
nested router의 기본 route 지정해주기
안녕하세요. 오늘은 제가 올해 초 진행했었던 유튜브 뮤직 클론코딩 프로젝트를 리팩토링하면서.  개선이 필요한 코드를 발견하여 수정한 경험을 공유해보고자 합니다. 해당 페이지에서는 nested router를 통해 재생목록, 앨범, 노래, 아티스트, 구독 탭을 보여주고 있습니다. url에 child route를 입력해주지 않은 경우 탭에서 아무것도 보여주지 않아 리다이렉트를 시켜주고자 했었던 것 같습니다. 여기서 문제는 ... mounted 훅에서 검사를 시켜주고 있었네요 ...
Fetch API와 created hook에서 API 호출 시 차이 thumbnail
Fetch API와 created hook에서 API 호출 시 차이
안녕하세요. 오늘은 Nuxt의 Fetch API와 라이프사이클의 created 훅에서 API를 호출 했을 때 각각의 차이점에 대해 알아보겠습니다. SSR 기준으로, Nuxt 2.12 버전 이후 Fetch API는 Vue instance가 create 된 후에 실행 됩니다. 그러면 created 훅에서 실행하는 편이 더 빠른 API response를 가져오지 않을까 궁금해졌는데요. 이를 따로 비교한 글을 찾지 못해 제가 직접 한번 테스트를 해보았습니다.
Nuxt 프로젝트에서 환경변수 관리하기 (feat. dotenv) thumbnail
Nuxt 프로젝트에서 환경변수 관리하기 (feat. dotenv)
서비스를 배포하실 때 환경별로 포트번호 등이 달라서 소스코드를 직접 수정하셨던 경험이 있으신가요 ? 포트번호와 같이 환경별로 다른 값이나 DB 접속 정보 등 소스코드에 포함되면 안될 중요한 값을 dotenv 패키지를 통해서 환경변수로 관리한다면, 별도의 추가 작업을 할 일이 없으실 겁니다.