));
}
```
```tsx
// LikeButton.tsx
'use client';
import { useState } from 'react';
import { likePost } from './actions';
export function LikeButton({ postId, initialCount }: ...) {
const [count, setCount] = useState(initialCount);
return (
);
}
```
### Server Action
```ts
// app/posts/actions.ts
'use server';
import { revalidatePath, revalidateTag } from 'next/cache';
import { z } from 'zod';
const Like = z.object({ postId: z.string().uuid() });
export async function likePost(postId: string) {
Like.parse({ postId });
const userId = await getUser();
if (!userId) throw new Error('UNAUTHORIZED');
await db.posts.update({ where: { id: postId }, data: { likes: { increment: 1 } } });
revalidatePath(`/posts/${postId}`);
revalidateTag('posts');
}
```
### Form action
```tsx
// app/new-post/page.tsx
import { createPost } from './actions';
export default function NewPost() {
return (
);
}
```
```ts
// actions.ts
'use server';
import { redirect } from 'next/navigation';
import { z } from 'zod';
const NewPost = z.object({ title: z.string().min(1), body: z.string().min(1) });
export async function createPost(formData: FormData) {
const data = NewPost.parse(Object.fromEntries(formData));
const post = await db.posts.create({ data });
redirect(`/posts/${post.id}`);
}
```
### useFormStatus / useActionState (React 19)
```tsx
'use client';
import { useFormStatus } from 'react-dom';
import { useActionState } from 'react';
function SubmitButton() {
const { pending } = useFormStatus();
return ;
}
function Form() {
const [state, formAction] = useActionState(createPost, { error: null });
return (
);
}
```
### Cache 제어 (Next 15 — 명시 opt-in)
```ts
// 'use cache' (실험)
async function getPosts() {
'use cache';
return db.posts.findMany();
}
// 또는 fetch 옵션
fetch(url, { next: { revalidate: 60, tags: ['posts'] } });
```
### Streaming + Suspense
```tsx
import { Suspense } from 'react';
async function SlowPanel() {
await new Promise(r => setTimeout(r, 2000));
return
slow data
;
}
export default function Page() {
return (
<>
}>
>
);
}
```
각 Suspense 경계가 별도 stream — 빠른 부분 먼저.
### Server Action 보안
```ts
'use server';
export async function deletePost(id: string) {
const user = await getUser();
if (!user) throw new Error('UNAUTHORIZED');
const post = await db.posts.findUnique({ where: { id } });
if (post?.userId !== user.id) throw new Error('FORBIDDEN');
await db.posts.delete({ where: { id } });
revalidatePath('/posts');
}
```
⚠️ Server Action 은 public endpoint — 자동 권한 검사 X.
### useOptimistic
```tsx
'use client';
import { useOptimistic } from 'react';
function Likes({ initial, postId }: ...) {
const [opt, setOpt] = useOptimistic(initial);
return (
);
}
```
## 🤔 의사결정 기준
| 상황 | 추천 |
|---|---|
| 데이터 fetch | RSC (서버) |
| Form / mutation | Server Action |
| 인터랙션 (state, event) | 'use client' |
| 차트 / 라이브러리 (browser only) | 'use client' |
| 큰 데이터 stream | Suspense 분할 |
| External API mutation 그대로 | Server Action |
| RPC 일관성 필요 | tRPC (server actions 위 생산성) |
## ❌ 안티패턴
- **Server Action 인증 검사 누락**: public endpoint — 임의 호출 가능.
- **Client component 안에 큰 server import**: bundle 폭발.
- **Server Action 안 redirect 후 코드**: 실행 안 됨 (throw).
- **revalidate 안 함**: cache 가 stale.
- **`'use client'` 도배**: bundle 큼. 가능한 RSC.
- **Server function 의 input 검증 X**: 위험. zod.
- **Promise 직접 prop 으로 client 에**: serialize 안 됨. 데이터만.
- **Server / client 코드 한 파일 (export 둘 다)**: 모듈 분리.
## 🤖 LLM 활용 힌트
- Data fetch = RSC, Mutation = Server Action.
- Server Action = public — 권한 + 검증 매번.
- revalidatePath / revalidateTag 필수.
## 🔗 관련 문서
- [[React_Server_Components]]
- [[React_Form_State_Patterns]]
- [[React_TanStack_Query_Advanced]]