본문 바로가기
Frontend/Next.js

[Next.js] Data Mutation: Server Actions

by 그냥하는거지뭐~ 2024. 7. 16.

1. Server Actions

Next.js의 server component는 hydrate되지 않은, 즉 interactive하지 않은 컴포넌트로 useState, useEffect 없이 데이터를 불러올 수 있다. 데이터를 변경시켜야 하는 상황에서는 server actions를 사용하면 API route 없이 서버 측에서 직접 DB에 접근할 수도 있고, 페이지 렌더링 과정에 참여하게 된다. 기존에 browser -> (front server ->) backend server -> DB 의 구조를 browser -> front server -> DB로 줄인 것이다. 

장점
- network 요청 수를 줄일 수 있다
- SEO 향상
- 민감한 데이터를 클라이언트 측에 노출시키지 않고 처리할 수 있다
- client side bundle 사이즈가 줄어든다 -> 초기 로딩 속도 개선 

 

Server actions를 사용하면 어떤 것들이 좋고 프로젝트에 어떤 식으로 녹여낼 수 있을지 알아보자. 


2. BoilerPlate  

2.1. Old way 

지금까지의 data fetching 방식을 살펴보자. 

import { useState } from 'react'

export default function FormOld() {
  const [inputText, setInputText] = useState('')

  const handleSubmit = async (event) => {
  	event.preventDefault()
    
    await fetch('/api/todos', {
      method: 'POST',
      body: JSON.stringify({ content: inputText }),
      headers: {
        'Content-Type': 'application/json',
      },
    })
  }
  return (
    <form onSubmit={handleSubmit}>
      <input
        type="text"
        name="content"
        placeholder="Write your todo"
        onChange={(e) => setInputText(e.target.value)}
      />
    </form>
  )
}

useState로 input에 들어가는 inputText를 관리해주고, '/api/todos'라는 route도 서버와 약속해야 하고, submit 시 POST로 inputText를 담아서 보낸다. 서버는 이 정보를 받아 DB에 저장하거나 다른 작업을 수행한다. 즉, client -> api -> DB -> api -> client의 과정으로 api를 거치는 것은 필수적이다. 

 

2.2. New way

server actions를 사용해서 바꿔보자. 

import { addTodo } from '@/actions'

export default function FormNew() {
  return (
    <form action={addTodo}>
      <input type="text" name="content" placeholder="Write your todo" />
      <button>Add</button>
    </form>
  )
}

 

이제 onSubmit 대신 action이라는 attribute에 server action을 전달해준다. action은 form이 submit되면 실행되는 함수이다. 보다시피 API route가 필요없고, state를 관리할 필요도 없다. 

// actions.ts
'use server'

import { prisma } from '@/db'

export const addTodo = async (formData: FormData) => {
  const content = formData.get('content')

  await prisma.todo.create({
    data: {
      content: content as string,
    },
  })
  return {
    success: true,
  }
}

 

server action 코드를 보면, 'use server'를 사용해서 서버에서만 동작하는 API라는 것을 명시해주고, 바로 DB에 저장했다. 여기까지만 해주면 DB에는 저장됐지만, UI에는 보이지 않는다. (새로고침하면 보인다)

 

revalidatePath나 revalidateTag를 써주면, 새로고침 없이도 바로 UI 업데이트가 가능하다. 이들은 똑똑하게도 변경된 데이터만 바꿔준다. 브라우저의 network 탭에 들어가보면 next.js가 알아서 request를 보낸 것을 확인할 수 있다. 또한 이 모든 과정은 javascript가 disabled된 상태에서도 동작하는데, 이를 progressive enhancement라고 하며, 뒤에서 자세히 다룰 예정이다.

 

server actions는 server/client component에서 모두 사용할 수 있다. client component에서 server action을 수행하기 전에 form을 reset한다던가, validation을 수행할 수도 있다. 


3. Server actions + loading state

#useFormStatus #useActionState #useOptimistic 

 

원래 우리가 리액트에서 loading state를 처리하던 방식을 생각해보자. useState로 isLoading, setIsLoading을 관리하면서 data fetch가 이루어지기 전에 setIsLoading(true), 완료되면 setIsLoading(false) 이런식으로 수동으로 하나하나 처리해줘야 했다. (지저분)

 

3.1. useFormStatus

 

useFormStatus – React

The library for web and native user interfaces

react.dev

따끈따끈한 훅이다(canary, experimental channel에서만 사용 가능). 

 const { pending, data, method, action } = useFormStatus();

 

import { addTodo } from '@/actions'
import { useFormStatus } from 'react-dom'

export default function FormNew() {
  const { pending } = useFormStatus()
  return (
    <form action={addTodo}>
      <input type="text" name="content" placeholder="Write your todo" />
      <button>{pending ? 'Adding...' : 'Add'}</button>
    </form>
  )
}

 

지금 상태로는 동작하지 않는데, 이 훅은 form 안에 있어야 해서 button component를 따로 빼서 다음과 같이 사용해야 한다. 

export const Button = () => {
  const { pending } = useFormStatus()
  return <button>{pending ? 'Adding...' : 'Add'}</button>
}

 

form 안에 써야하는게 불편하다면 다음에 소개할 useActionState를 쓰면 된다. 

 

 

3.2. useActionState

 

useActionState – React

The library for web and native user interfaces

ko.react.dev

const [state, formAction] = useActionState(fn, initialState, permalink?);
"use client"

import { addTodo } from '@/actions'
import { useActionState } from 'react'

export default function FormNew() {
  const [error, action, isPending] = useActionState(addTodo, null)
  return (
    <form action={action}>
      <input type="text" name="content" placeholder="Write your todo" />
      <button disabled={isPending}>{isPending ? 'Adding...' : 'Add'}</button>
      {error && <p>{error}</p>}
    </form>
  )
}

 

useActionState의 첫 번째 매개변수로 server action을 전달하고, 두 번째는 initialState를 전달한다. 

import { prisma } from '@/db'
import { revalidatePath } from 'next/cache'

export const addTodo = async (previousState, formData: FormData) => {
  const content = formData.get('content')

  try {
    const content = formData.get('content') as string

    await prisma.todo.create({
      data: {
        content: content as string,
      },
    })
  } catch (e) {
    return 'Error occured'
  }

  revalidatePath('/')
}

action은 현재 state를 첫 번째 인수로 받는다. 이 action에서 error를 return 하고 있기 때문에 state 자리에 error라고 표기한 것이지 사실 return하는 아무거나 써도 된다. 이 훅은 form에만 쓸 수 있는 것은 아니다. 

 

 

loading state를 컴포넌트 외부에서 다루는 것은 지저분한 느낌이 있었는데, useFormStatus, useActionState가 깔끔하게 해결해줬다!

 

 

3.3. useOptimistic

 

useOptimistic – React

The library for web and native user interfaces

ko.react.dev

const [optimisticState, addOptimistic] = useOptimistic(state, updateFn);

 

loading 시간마저 기다리지 않고 성공했다고 가정하고 성공했을 때의 UI를 즉각적으로 보여줄 수 있는 훅이다. 대부분의 요청은 성공할 것이기 때문에 response가 도착하기 전에 미리 화면을 update시켜버리는 것이다. 혹시라도 실패하면 되돌린다. Next.js docs에서도 적극적으로 권유하고 있으며, 사용 방법까지 자세하게 설명하고 있다. 

 

 

Data Fetching: Server Actions and Mutations | Next.js

Learn how to handle form submissions and data mutations with Next.js.

nextjs.org

 

 


4. Progressive Enhancement 

Progressive enhancement is a design philosophy that provides a baseline of essential content and functionality to as many users as possible, while delivering the best possible experience only to users of the most modern browsers that can run all the required code.
- MDN

 

즉, progressive Enhancement는 모든 사용자에게 일관된 경험을 제공하면서, 가능할 때 더 나은 경험을 제공하는 중요한 설계 철학이다. Next.js에서 server component는 기본적으로 progressive enhancement를 지원하는데, javascript가 로드되지 않았거나 disabled된 상태에서도 form이 submit되는 것도 이에 포함된다. Client component에서 form action에 server action을 담고 useActionState를 사용해보면 js를 disabled시킨 상태에서도 submit되는 것을 확인할 수 있다. 즉, progressive enhancement를 유지했다. Javascript 없이도 동작할 수 있다는 뜻은 해당 코드가 client side bundle에 포함되지 않는다는 말이며, bundle 사이즈가 줄어드는 효과도 있다. 

Next.js에서 progressive enhancement를 권장하고 있는 만큼, 이를 고려한 코드를 짜기 위해 고민해보자! 

 

 


Reference

- https://nextjs.org/docs/app/building-your-application/data-fetching/server-actions-and-mutations

- https://developer.mozilla.org/en-US/docs/Glossary/Progressive_Enhancement