이전 포스팅에서는
- Server-Action으로 업데이트 한 데이터를 어떻게 화면에 바로 반영하는지
- 로딩 중 상태는 어떻게 보여줄 수 있는지
에 대해서 다뤘다.
이번 포스팅에서는
1. 유효성 검사와 error Handling은 어떻게 하는지
2. Server-Action으로 낙관적 업데이트는 어떻게 할 수 있는지, useOptimistic()의 버그(?)
에 대해서 다룰 예정이다.
1. Server-Action에서 유효성 검사와 ErrorHandling은 어떻게 할까?
유효성 검사 - zod 라이브러리 사용
왜 zod를 사용해야 하는가?
보통 Server-Action을 사용하지 않고 유효성 검사를 할 때에는
useState()를 사용해서 form 태그나 input 태그에 입력되는 값을 state에 할당하고,
그 값이 유효한지를 정규식이나 JS 함수(inlcude, startswith 등)를 사용해서 실행했었다.
그러나 Server-Action은 JS와 별도로 동작하기 때문에
server 측에서 실행되는 유효성 검사 도구가 필요하기 때문이다.
설치
tsconfig.json 파일 확인
// tsconfig.json
{
// ...
"compilerOptions": {
// ...
"strict": true
}
}
schema 만들기
// app > lib > schema.ts
import { z } from 'zod';
export const testSchema = z.object({
name: z
.string({ required_error: 'Name is required', invalid_type_error: 'Name must be a string' })
.min(1, { message: '최소 1글자 이상이어야 합니다.' })
.max(5, { message: '최대 5글자 이하여야 합니다.' }),
message: z
.string({ required_error: 'Message is required', invalid_type_error: 'Message must be a number' })
.min(1, { message: '최소 1글자 이상이어야 합니다.' })
.max(10, { message: '최대 10글자 이하여야 합니다.' })
});
- Zod에서 제공해주는 유효성 검사 함수들이 여러 개 있다.
위 코드의 경우에는
required_error: 아무 값이 입력되지 않았을 때
invalid_type_error: 타입 오류일 때
.min(1): 최소 1 글자
.max(5): 최대 5 글자
라는 뜻이다.
이 외에도 zod 공식문서에 보면 제공해주는 유효성 검사 함수가 되게 많다.
Server-Action에 Zod import & 활용하기
export const serverAdd = async (data: FormData) => {
const formData = Object.fromEntries(data);
// 외부 통신 전 유효성 검사 먼저
const { error: zodErr } = testSchema.safeParse(formData);
if (zodErr) {
return { error: zodErr.format() };
}
try {
await addDoc(collection(db, 'test'), formData);
revalidatePath('/daily');
} catch (e) {
throw new Error('fail to add a new item');
}
};
shema.parse() 함수도 있고 safeParse() 함수도 있다.
parse() 는 ZodError 자체를 뱉기 때문에 Error Boundary에 걸린다.
safeParse() 는 오류 데이터 객체를 반환한다.
나는 유효성 검사를 통과하지 못하면 input 창 밑에 바로 오류 메세지를 보여주기 위해서
오류 데이터 객를 반환해주는 safeParse()를 사용했다.
'use client'
import { serverAdd } from ' @/app/action';
const DailyClientComponent = () => {
const [validationErr, setValidationErr] = useState<ValidationErr | null>(null);
const serverActionWithZod = async (data: FormData) => {
const result = await serverAdd(data);
if (result?.error) {
setValidationErr(result.error);
} else {
setValidationErr(null);
}
formRef.current?.reset();
};
return (
...
<div className="border-4 py-4 px-4">
<form action={serverActionWithZod} ref={formRef} className="flex flex-col gap-2">
<h1>server-action Imported with Zod: </h1>
<input type="text" name="name" className="border" placeholder="string 형식이어야 합니다."></input>
{validationErr ? <p>{validationErr.name?._errors}</p> : null}
<input type="number" name="message" className="border" placeholder="number 형식이어야 합니다."></input>
{validationErr ? <p>{validationErr.message?._errors}</p> : null}
<button className="border">send</button>
</form>
</div>
...
)}
Error Handling
Server-Action도 마찬가지로 error.tsx 파일을 만들어서 Error-Boundary를 만들었다.
특히 form 태그에 Server-Action을 연결하고 그 컴포넌트 자체를 Server 컴포넌트로 만들면
Client 컴포넌트로 만들었을 때보다 Error-Boundary로 UX 가 좀 더 개선되는 듯 하다.
'use client'; // Error components must be Client Components
import { useEffect } from 'react';
export default function Error({ error, reset }: { error: Error & { digest?: string }; reset: () => void }) {
return (
<div>
<h2>Something went wrong!</h2>
<button
onClick={
() => reset()
}
>
Try again
</button>
</div>
);
}
왜냐하면,
에러 발생 시 Error-boudary 즉, Error 페이지로 이동하고 try-again을 누르면 해당 페이지로 redirect가 되는데,
Client 컴포넌트로 만들면 client 페이지로 redirect 되면서 또 오류가 있는 함수(useQuery가 문제라면)가 실행되며
다시 error-boundary에 걸림 -> Error 페이지로 감
그런데 Server 컴포넌트로 만들면, try-again을 누를 시 Server 컴포넌트를 가진 페이지로 redirect가 되면서
오류가 있는 함수는 form 태그 내 action이 실행될 때만 실행되기 때문에 에러 페이지가 아니라 원래 페이지가 렌더링 됨
=> 아주 사소한 것이지만, User의 입장에서는 try-again을 아무리 눌러도 Error 페이지만 렌더링 되는 것 보다는, 원래 페이지가 보이는 게 나을 것 같다 생각했다.
2. Server-Action에서의 낙관적 업데이트 - useOptimistic()
Next.js 공식문서에서는 useOptimistic()를 제안한다.
useOptimistic()은 React에서 제공하는 Canary hook으로 아직 실험적인 hook 이다.
그래서 사용해보았는데.. 약간의 버그가 있다.
form 태그 action prop에 넘겨지는 Server-Action 함수에 네트워크 통신 로직이 있으면
UI가 끊기는 현상이 발생한다.
코드로 보면
'use client';
import { deliverMessage } from ' @/app/action';
import { useOptimistic, useRef, useState } from 'react';
const UseOptimistic = () => {
const [msg, setMsg] = useState<
{
text: string | unknown;
sending: boolean;
}[]
>([{ text: '', sending: false }]);
const formRef = useRef<HTMLFormElement | null>(null);
const serverActionWithUseOptimistic = async (formData: FormData) => {
const bindServerAction = deliverMessage.bind(null, msg);
await bindServerAction(formData);
setMsg((prev) => [...prev, { text: '이거 되나', sending: false }]);
formRef.current?.reset();
};
const [optimisticMessages, addOptimisticMessage] = useOptimistic(msg, (state, newMessage) => [
...state,
{
text: newMessage,
sending: true
}
]);
const action = async (formData: FormData) => {
addOptimisticMessage(formData.get('name'));
await serverActionWithUseOptimistic(formData);
};
return (
<div className="border-4 py-4 px-4">
<form action={action} className="flex flex-col gap-2" ref={formRef}>
<h1>Optimistic Server-action : </h1>
<input type="text" name="name" className="border"></input>
<input type="number" name="message" className="border"></input>
<button className="border">send</button>
</form>
{optimisticMessages.map((message: any, index) => (
<div key={index}>
{message.text.length ? message.text : null}
{!!message.sending && <small> (Sending...)</small>}
</div>
))}
</div>
);
};
export default UseOptimistic;
// app > action.ts
export const deliverMessage = async (message: { text: string | unknown; sending: boolean }[], data: FormData) => {
await new Promise((res) => setTimeout(res, 1000));
const formData = Object.fromEntries(data);
try { // 외부 네트워크 통신 로직
await addDoc(collection(db, 'optimistic'), formData);
revalidatePath('/daily');
} catch (e) {
console.error(e);
throw new Error('fail to add a new item');
}
return message;
};
> 1이 없어짐과 동시에 "이거 되나"가 생겨야 하는데, 이거 되나가 생기고도 1이 없어지지 않는 순간이 있다.
공식문서에는 네트워크 통신 로직이 없었기 때문에, deliverMessage() 함수에 있던 네트워크 통신 로직을 제외해봤다.
export const deliverMessage = async (message: { text: string | unknown; sending: boolean }[], data: FormData) => {
await new Promise((res) => setTimeout(res, 1000));
// const formData = Object.fromEntries(data);
// try {
// await addDoc(collection(db, 'optimistic'), formData);
// revalidatePath('/daily');
// } catch (e) {
// console.error(e);
// throw new Error('fail to add a new item');
// }
return message;
};
이렇게 하면
끊기는 현상 없이 잘 렌더링 된다.
이 이유에 대해서는 진짜 React 측 Canary 버전의 버그인지,
아니면 내가 무언가를 잘못하고 있는지 더 생각해봐야 할 것 같다.
우선 Server-Action으로 3일을 날려버려서 여기까지만 포스팅 해야겠다ㅠ
+) 추가로 Server-Action에 대해 공부하다가 You-tube 영상을 보게 되었는데
https://www.youtube.com/watch?v=sdKFEo6978U
처음부터 끝까지 이해가 잘 되게 설명해준다.
직접 댓글로 질문해서 답도 얻어냈다ㅋㅋ
영상 마지막 부분에 Server-Action의 실행이 Hydration이 다 끝날 때까지 대기열에 기다렸다가 실행된다는 말이 있어서,
분명 Server-Action은 JS의 관여없이 동작할 수 있다고 했는데 무슨소리지?? 해서 질문을 남겼다.
Server-Action의 실행은 Server 컴포넌트 안에 Server-Action이 있으면 바로 실행되고, Client 컴포넌트 안에 있으면 Hydration이 다 된 뒤에 실행된다고 한다.
유레카!
'TIL' 카테고리의 다른 글
2024.06.20 SQL문으로 기존 배열에 데이터 갈아끼우지 않고 추가하기 (0) | 2024.06.22 |
---|---|
2024.06.10 drag_라이브러리_없이_구현_중_트러블슈팅 1 (1) | 2024.06.12 |
2024.05.30 #Next.js #Server-Action2 #화면반영 #로딩상태 (0) | 2024.05.31 |
2024.05.29 #Next.js #Server-Action1 (1) | 2024.05.31 |
2024.05.28 #Next.js #How are Client Components Rendered? #추론 (0) | 2024.05.29 |