출시·고도화 중
React 안내서 · 4/6
앱이 커지면 상태를 어디에 둘지, 서버 데이터를 어떻게 불러올지, 폼 입력을 어떻게 다룰지가 중요해집니다. 이 장에서는 상태 끌어올리기, Context, 리듀서, 데이터 불러오기, 폼을 차례로 다룹니다.
두 컴포넌트가 같은 데이터를 써야 한다면, 상태를 둘의 가장 가까운 공통 부모로 옮기고 props로 값과 변경 함수를 내려 줍니다. 이렇게 하면 데이터의 출처가 한 곳(single source of truth)으로 유지됩니다.
import { useState } from "react";
function TemperatureInput({ value, onChange }: { value: number; onChange: (v: number) => void }) {
return <input type="number" value={value} onChange={(e) => onChange(Number(e.target.value))} />;
}
export function Thermostat() {
const [celsius, setCelsius] = useState(20);
return (
<div>
<TemperatureInput value={celsius} onChange={setCelsius} />
<p>화씨 {(celsius * 9) / 5 + 32}도</p>
</div>
);
}테마, 로그인 사용자, 언어처럼 여러 단계 아래의 컴포넌트가 함께 쓰는 값을 매번 props로 넘기면 번거롭습니다(prop drilling). createContext로 Context를 만들고 위에서 값을 제공하면, 아래의 어떤 컴포넌트든 useContext로 읽을 수 있습니다. 최신 React에서는 Context 자체를 Provider로 쓸 수 있으며, 이전 코드에서는 <ThemeContext.Provider> 형태도 볼 수 있습니다.
import { createContext, useContext } from "react";
type Theme = "light" | "dark";
const ThemeContext = createContext<Theme>("light");
export function App({ theme }: { theme: Theme }) {
return (
<ThemeContext value={theme}>
<Toolbar />
</ThemeContext>
);
}
function Toolbar() {
const theme = useContext(ThemeContext);
return <div className={`toolbar toolbar-${theme}`}>현재 테마: {theme}</div>;
}Context 값이 바뀌면 그 값을 읽는 모든 컴포넌트가 다시 렌더링됩니다. 자주 바뀌는 큰 상태를 하나의 Context에 몰아넣기보다 용도별로 나누는 편이 좋습니다.
상태를 바꾸는 방법이 여러 가지라면 useReducer로 변경 규칙을 한 함수에 모읍니다. 컴포넌트는 "무엇이 일어났는지"를 담은 액션만 보내고(dispatch), 리듀서가 새 상태를 계산합니다. TypeScript의 구별된 유니온으로 액션을 정의하면 빠진 경우를 잡아낼 수 있습니다.
import { useReducer } from "react";
type Todo = { id: number; text: string; done: boolean };
type Action =
| { type: "added"; text: string }
| { type: "toggled"; id: number };
function todosReducer(todos: Todo[], action: Action): Todo[] {
switch (action.type) {
case "added":
return [...todos, { id: Date.now(), text: action.text, done: false }];
case "toggled":
return todos.map((t) => (t.id === action.id ? { ...t, done: !t.done } : t));
}
}
export function useTodos() {
const [todos, dispatch] = useReducer(todosReducer, []);
return { todos, add: (text: string) => dispatch({ type: "added", text }), dispatch };
}리듀서와 Context를 함께 쓰면 작은 앱에서는 별도 라이브러리 없이도 전역 상태를 관리할 수 있습니다. 더 큰 앱에서는 Zustand, Redux Toolkit 같은 상태 관리 라이브러리를 검토합니다.
Effect 안에서 fetch를 직접 호출할 수 있지만, 응답 순서가 뒤바뀌는 경쟁 상태를 막으려면 정리 함수에서 요청을 취소해야 합니다.
import { useEffect, useState } from "react";
type User = { id: number; name: string };
export function UserName({ userId }: { userId: number }) {
const [user, setUser] = useState<User | null>(null);
useEffect(() => {
const controller = new AbortController();
fetch(`/api/users/${userId}`, { signal: controller.signal })
.then((res) => res.json() as Promise<User>)
.then(setUser)
.catch((err) => {
if (err.name !== "AbortError") console.error(err); // 취소는 무시
});
return () => controller.abort();
}, [userId]);
return <span>{user ? user.name : "불러오는 중…"}</span>;
}캐시, 재시도, 로딩 · 오류 상태, 화면 복귀 시 갱신까지 직접 구현하기는 어렵습니다. 그래서 실제 앱에서는 TanStack Query 같은 라이브러리를 많이 씁니다. main.tsx에서 QueryClientProvider로 앱을 감싼 뒤 다음처럼 씁니다.
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
type Todo = { id: number; title: string };
export function Todos() {
const queryClient = useQueryClient();
const { data, isPending, error } = useQuery({
queryKey: ["todos"],
queryFn: async (): Promise<Todo[]> => (await fetch("/api/todos")).json(),
});
const addTodo = useMutation({
mutationFn: (title: string) =>
fetch("/api/todos", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ title }),
}),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["todos"] }),
});
if (isPending) return <p>불러오는 중…</p>;
if (error) return <p>오류: {error.message}</p>;
return (
<>
<ul>{data.map((t) => <li key={t.id}>{t.title}</li>)}</ul>
<button onClick={() => addTodo.mutate("새 할 일")}>추가</button>
</>
);
}입력값을 상태에 묶는 제어 컴포넌트는 입력할 때마다 검사하거나 값을 바꿀 때 유용합니다. 최신 React에서는 <form action={...}>에 함수를 넘기고 useActionState로 결과와 진행 상태를 받는 방식도 쓸 수 있습니다.
import { useActionState } from "react";
type State = { error: string | null };
async function subscribe(_prev: State, formData: FormData): Promise<State> {
const email = String(formData.get("email") ?? "");
if (!email.includes("@")) return { error: "이메일 형식이 아닙니다." };
await fetch("/api/subscribe", { method: "POST", body: formData });
return { error: null };
}
export function SubscribeForm() {
const [state, formAction, isPending] = useActionState(subscribe, { error: null });
return (
<form action={formAction}>
<input name="email" type="email" required />
<button disabled={isPending}>{isPending ? "보내는 중…" : "구독"}</button>
{state.error && <p role="alert">{state.error}</p>}
</form>
);
}입력 항목이 많고 검사 규칙이 복잡하면 React Hook Form 같은 폼 라이브러리를 함께 쓰는 것도 좋은 선택입니다.
useReducer로 다룹니다.useActionState로 다룹니다.
댓글 0개
로그인 · 로그인하면 댓글을 남길 수 있습니다.
첫 댓글을 남겨 보세요.