Next.js 13/14 App Router에서 useState 에러 해결하기
- 원인: App Router의
app디렉토리 컴포넌트는 기본이 서버 컴포넌트라서useState같은 React Hook을 쓸 수 없음 - 해결: 파일 최상단에
"use client"선언 / 상태가 필요한 부분만 별도 컴포넌트로 분리해 그쪽에만 지시어를 붙이는 게 권장 방법 - 주의: 페이지 전체를
"use client"로 돌리면 서버 컴포넌트의 이점(빠른 로딩, 번들 크기 감소)을 통째로 포기하게 됨
"use client"; // 파일 맨 첫 줄, import보다도 위
import { useState } from 'react';
↓ 왜 이런 에러가 나는지 원리 보기
App Router로 넘어온 지 얼마 안 된 분들이 자주 마주치는 에러가 useState나 useEffect를 선언했을 때 뜨는 "useState only works in Client Components"입니다. 왜 나는지와, 무작정 "use client"를 페이지 전체에 붙이는 것 말고 제대로 된 해결 방법을 정리했습니다.
에러가 발생하는 근본적인 원인
Next.js App Router 환경에서는 app 디렉토리 내부에 만드는 모든 컴포넌트가 기본적으로 '서버 컴포넌트(Server Component)'로 취급됩니다. 서버 컴포넌트는 오직 서버 환경(Node.js runtime)에서만 빌드되고 실행되는 컴포넌트입니다. 따라서 브라우저에서 일어나는 이벤트 리스닝, 상태 관리, 라이프사이클 이펙트 등을 다루는 React Hook을 사용할 수 없습니다. 처음 App Router로 넘어올 때는 이 규칙을 모르고 페이지 최상단에 무작정 "use client"를 붙여버리는 경우가 흔한데, 그러면 서버 컴포넌트의 이점(빠른 로딩, 번들 크기 감소)을 그대로 포기하게 되니 주의가 필요합니다.
콘솔창에 노출되는 대표적인 에러 메시지
- Error: useState only works in Client Components but none of its parents are marked with "use client", so they're Server Components by default.
- ReactServerComponentsError: You're importing a component that needs useState. It only works in a Client Component...
서버 컴포넌트 vs 클라이언트 컴포넌트 역할 비교
서버 컴포넌트는 기본값으로, API 키 같은 보안 정보를 다루거나 DB에서 데이터를 직접 페칭할 수 있지만 useState·useEffect나 onClick·onChange 같은 이벤트 처리는 쓸 수 없습니다. 반면 클라이언트 컴포넌트는 파일 최상단에 "use client"를 선언해야 하며, 그 대신 브라우저 전용 API와 useState·useEffect, 인터랙티브 이벤트 핸들러를 모두 사용할 수 있습니다.
useState 에러를 해결하는 2가지 올바른 방법
방법 1: 파일 최상단에 "use client" 지시어 추가
가장 단순한 해결책은 해당 컴포넌트 파일의 맨 첫 줄에 "use client" 문자열을 선언해 주는 것입니다. 이를 통해 Next.js 컴파일러에게 "이 컴포넌트는 클라이언트 단에서 해석해라"라고 직접 지시하게 됩니다.
// 반드시 임포트 문보다 더 위에, 맨 첫 줄에 작성해야 합니다.
"use client";
import { useState } from 'react';
export default function Counter() {
const [count, setCount] = useState(0);
return (
<div>
<p>현재 카운트: {count}</p>
<button onClick={() => setCount(count + 1)}>증가</button>
</div>
);
}
방법 2: 컴포넌트 분리 설계 (권장 방법)
페이지 전체를 "use client"로 변경하면, 해당 페이지 내부의 모든 자식 요소들이 서버 컴포넌트의 장점(빠른 데이터 로딩, 번들 사이즈 감소 등)을 누리지 못하게 됩니다. 따라서 "인터랙션(상태 변화)이 필요한 부분만 별도의 클라이언트 컴포넌트로 떼어내는 것"이 최적의 구조입니다.
[자식 컴포넌트 분리: LikeButton.tsx]
"use client";
import { useState } from 'react';
export default function LikeButton() {
const [liked, setLiked] = useState(false);
return (
<button onClick={() => setLiked(!liked)}>
{liked ? '좋아요 취소' : '좋아요'}
</button>
);
}
[부모 서버 컴포넌트 페이지: page.tsx]
import LikeButton from './LikeButton';
async function getPostData() {
const res = await fetch('https://api.example.com/post/1');
return res.json();
}
export default async function PostPage() {
const post = await getPostData();
return (
<main style={{ padding: '20px' }}>
<h1>{post.title}</h1>
<p>{post.content}</p>
{/* 상태 관리가 필요한 부분만 클라이언트 컴포넌트로 삽입 */}
<LikeButton />
</main>
);
}
예전에 Pages Router 프로젝트를 App Router로 옮기던 중, 페이지 최상단 컴포넌트 하나에만 "use client"를 빼먹고 넘어간 적이 있습니다. 그 컴포넌트 내부에 있던 검색창의 useState만 에러가 나서 처음엔 그 파일만 살펴봤는데, 알고 보니 검색창을 감싸고 있던 상위 레이아웃 컴포넌트가 서버 컴포넌트로 남아있어서 그 아래 트리 전체가 영향을 받고 있었습니다. 에러 메시지에 찍히는 파일과 실제로 지시어를 넣어야 할 파일이 다를 수 있다는 걸 그때 알았습니다.
요약: 컴포넌트 설계 시 자가진단 기준
오류 없는 렌더링 구조를 위한 자가진단 기준입니다.
| 컴포넌트 속성 | 판단 기준 | 적용할 지시어 |
|---|---|---|
| 데이터 페칭 / 보안 | API Key가 노출되면 안 되거나 백엔드 DB와 직접 통신할 때 | 생략 (Default) |
| 이벤트 처리 | onClick, onChange 등 사용자 마우스/키보드 입력을 처리할 때 |
"use client" |
| 상태 관리 | useState, useReducer, useContext 등의 상태관리가 포함될 때 |
"use client" |
| 라이프사이클 | useEffect를 활용하여 외부 리소스 동기화가 이루어질 때 |
"use client" |
서버 컴포넌트와 클라이언트 컴포넌트의 상호 작용 원리는 공식 레퍼런스에서 더 자세히 확인할 수 있습니다.
결국 서버/클라이언트 컴포넌트 구분도 자바스크립트가 브라우저와 서버에서 각각 어떻게 실행되는지 아는 데서 출발합니다. 그 기초가 헷갈리신다면 [호마다의 IT 개발 입문 블로그] 의 자바스크립트 기초 글도 참고해보세요.
댓글
댓글 쓰기