Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

/src/features/todo/components → /src/app/_components/Empty 이동되었습니다!

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

네, 만약 공용 폴더라면 app 바깥에 components 폴더를 만들어 유지해주시고,
특징 라우트그룹/ 페이지에서만 쓰이는 컴포넌트라면 이렇게 유지해주시면 될것같아요 :)

File renamed without changes.
22 changes: 22 additions & 0 deletions src/app/actions.ts
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

서버 액션을 사용해봤습니다. 파일 위치가 app 폴더 내에 있는게 좀 이상한거 같긴 한데, 공식 문서에서도 app 폴더 내에 위치하고 있어서 여기에 생성을 했습니다.

Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
"use server";

import { revalidateTag } from "next/cache";

import { createTodoItem } from "@/features/todo/services/todoApi";

export async function createTodoItemAction(_: any, formData: FormData) {
const name = formData.get("name")?.toString();

if (!name) return;

try {
const data = await createTodoItem(name);

revalidateTag("todoList");

return data;
} catch (err) {
console.log(err);
return;
}
}
2 changes: 2 additions & 0 deletions src/app/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import localFont from "next/font/local";

import type { Metadata } from "next";

import "./globals.css";
import Header from "@/components/Header";

Expand Down
34 changes: 14 additions & 20 deletions src/features/todo/components/TodoAddForm.tsx
Original file line number Diff line number Diff line change
@@ -1,38 +1,32 @@
"use client";

import { ChangeEvent, useActionState, useEffect, useState } from "react";

import { createTodoItemAction } from "@/app/actions";
import Button from "@/components/Button";
import { createTodoItem } from "@/features/todo/services/todoApi";
import { useRouter } from "next/navigation";
import { ChangeEvent, FormEvent, useRef, useState } from "react";

const TodoAddForm = () => {
const [state, formAction, isPending] = useActionState(
createTodoItemAction,
null
);
const [todoText, setTodoText] = useState("");
const loadingRef = useRef<boolean>(false);
const router = useRouter();
const isValid = !(todoText.trim().length > 0) || loadingRef.current;
const isValid = todoText.trim().length === 0 || isPending;

const handleChange = (e: ChangeEvent<HTMLInputElement>) =>
setTodoText(e.target.value);

const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
if (loadingRef.current) return; // 중복 요청 방지
try {
loadingRef.current = true;
await createTodoItem(todoText.trim());
router.refresh(); // 서버컴포넌트 새로고침
} catch (error) {
console.error(error);
} finally {
setTodoText(""); // input 초기화
loadingRef.current = false;
useEffect(() => {
if (state) {
setTodoText("");
}
};
}, [state]);

return (
<form className="flex gap-5" onSubmit={handleSubmit}>
<form className="flex gap-5" action={formAction}>
<input
type="text"
name="name"
value={todoText}
onChange={handleChange}
placeholder="할 일을 입력해주세요"
Expand Down
32 changes: 13 additions & 19 deletions src/features/todo/components/TodoListArea.tsx
Original file line number Diff line number Diff line change
@@ -1,23 +1,24 @@
"use client";

import DoneEmpty from "@/features/todo/components/DoneEmpty";
import { startTransition, useOptimistic, useRef } from "react";

import DoneEmpty from "@/app/_components/Empty/DoneEmpty";
import TodoEmpty from "@/app/_components/Empty/TodoEmpty";
import TodoContent from "@/features/todo/components/TodoContent";
import TodoEmpty from "@/features/todo/components/TodoEmpty";
import { updateTodoItem } from "@/features/todo/services/todoApi";
import { TodoItemType } from "@/types/todoTypes";
import { startTransition, useEffect, useOptimistic, useState } from "react";

interface Props {
data: TodoItemType[];
onUpdate?: (id: number) => void;
}

const TodoListArea = ({ data }: Props) => {
const [todoAll, setTodoAll] = useState(data);
const initialDataRef = useRef<TodoItemType[]>(data);
const [optimisticState, toggleOptimisticState] = useOptimistic<
TodoItemType[],
number
>(todoAll, (currentState, id) => {
>(initialDataRef.current, (currentState, id) => {
return currentState.map((todo) =>
todo.id === id ? { ...todo, isCompleted: !todo.isCompleted } : todo
);
Comment on lines +17 to 24
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

말씀하셨던대로 useOptimistic의 초기값을 data로 설정했지만 여전히 이전 데이터로 돌아가더라구요....
뭔가 page에서 내려주는 데이터가 최신화가 아닌 상태로 props로 계속 내려주고 있는 와중에 TodoListArea가 계속 렌더링 되면서 useOptimistic를 계속 초기화값으로 되돌리는게 아닌가라고 추측을 해서..(사실인지 아닌지는 잘 모르겠습니다..) 어쩔수 없이 useRef를 사용할 수 밖에 없었습니다..
여기 부분은 미션10에서 리액트쿼리 사용하면서 다시 적용해보겠습니다!

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ㅎㅎ 제가 이 문제를 전체 코드베이스 훑어보며 좀 파보니까, 구조적으로 이런 문제가 있더라고요

  • page.tsx 컴포넌트의 <TodoAddForm/>에서 새로운 할 일을 추가할 때 createTodoItemAction이 실행됨
  • 이 액션에서 revalidateTag("todoList")가 호출되어 캐시가 무효화됨
  • Next.js는 캐시가 무효화되면 서버 컴포넌트를 다시 렌더링하므로, page.tsx의 getTodoList()가 다시 호출되어 새로운 데이터를 가져오고
  • 이 과정을 통해 새로운 data prop이 TodoListArea에 전달됨
  • 새로운 data prop이 전달되면 useOptimistic의 초기값이 재설정됨

결론적으로 이 문제는 서버 컴포넌트의 리렌더링 때문에 발생하는 현상입니다.
서버 상태와 클라이언트 상태를 분리하셔야할것같아요.

예를 들면

const TodoListArea = ({ data }: Props) => {
  // 클라이언트 상태로 서버 데이터 관리
  const [clientData, setClientData] = useState(data);
...

이런식으로 클라이언트 상태로 서버 데이터를 관리하는게 부가적으로 필요할것같고,
useOptimistic의 초기값을 clientData로 고정한다음
서버 데이터가 변경될 때 + API 응답 성공시 클라이언트 데이터를 업데이트하는 과정이 필연적일것같네요.

결과를 보면 이전에 태경님이 생각하셨던 방식과 비슷하게 풀어가는게 좋을 것 같아요.
다만, 이제 정확한 구조적 원인을 파악했으니 이 문제를 클라이언트 / 서버 상태를 분리하는 방식으로 해결해본다는 점이 이전에 드렸던 피드백과 살짝 달라지겠네요 :)

한번 참고해보시고, 더 좋은 방법이 있을지 고민해보세요!
한단계 더 나아가기위한 좋은 리팩토링 주제입니다 👍

Expand All @@ -28,30 +29,23 @@ const TodoListArea = ({ data }: Props) => {
// 낙관적 업데이트
toggleOptimisticState(id);

const targetCheck = todoAll.find((todo) => todo.id === id)?.isCompleted;
const targetCheck = optimisticState.find(
(todo) => todo.id === id
)?.isCompleted;
const updateData = { isCompleted: !targetCheck };

const prevTodoAll = todoAll;

try {
setTodoAll((prevTodoAll) =>
prevTodoAll.map((todo) =>
todo.id === id ? { ...todo, isCompleted: !todo.isCompleted } : todo
)
);
await updateTodoItem(id, updateData);
const updateTodo = initialDataRef.current.map((todo) =>
todo.id === id ? { ...todo, isCompleted: !todo.isCompleted } : todo
);
initialDataRef.current = updateTodo;
} catch (error) {
console.error(error);

setTodoAll(prevTodoAll);
}
});
};

useEffect(() => {
setTodoAll(data);
}, [data]);

const todoData = optimisticState.filter((item) => !item.isCompleted);
const doneData = optimisticState.filter((item) => item.isCompleted);

Expand Down
11 changes: 6 additions & 5 deletions src/features/todo/services/todoApi.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import {
PatchTodoResponse,
PostTodoResponse,
TodoResponseType,
TodoItemType,
UpdateTodoData,
} from "@/types/todoTypes";
Expand All @@ -10,7 +9,9 @@ const API_END_POINT = process.env.NEXT_PUBLIC_SERVER_COMMON_END_POINT;
const BASE_URL = `${API_URL}${API_END_POINT}`;

export async function getTodoList(): Promise<TodoItemType[]> {
const res = await fetch(`${BASE_URL}/items`);
const res = await fetch(`${BASE_URL}/items`, {
next: { tags: ["todoList"] },
});

if (!res.ok) throw new Error(res.statusText);

Expand All @@ -19,7 +20,7 @@ export async function getTodoList(): Promise<TodoItemType[]> {
return data;
}

export async function createTodoItem(name: string): Promise<PostTodoResponse> {
export async function createTodoItem(name: string): Promise<TodoResponseType> {
const res = await fetch(`${BASE_URL}/items`, {
method: "POST",
headers: {
Expand All @@ -38,7 +39,7 @@ export async function createTodoItem(name: string): Promise<PostTodoResponse> {
export async function updateTodoItem(
id: number,
updateData: UpdateTodoData
): Promise<PatchTodoResponse> {
): Promise<TodoResponseType> {
const res = await fetch(`${BASE_URL}/items/${id}`, {
method: "PATCH",
headers: {
Expand Down
6 changes: 1 addition & 5 deletions src/types/todoTypes.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,15 +11,11 @@ export interface UpdateTodoData {
isCompleted?: boolean;
}

interface TodoResponseBase {
export interface TodoResponseType {
id: number;
tenantId: string;
name: string;
memo: string | null;
imageUrl: string | null;
isCompleted: boolean;
}

export interface PostTodoResponse extends TodoResponseBase {}

export interface PatchTodoResponse extends TodoResponseBase {}
Loading