1. React and Hooks

상태가 바뀌면 함수를 다시 실행한다는 것에서 출발하는 React 기초

  • Haram Lee
  • 2026-07-22
  • studies / Topics / React Forms

1.1 State In, UI Out

React를 한 문장으로 줄이면 “상태가 바뀌면 컴포넌트 함수를 다시 실행해서 화면을 새로 그리는 라이브러리"다. 문법보다 이 문장을 먼저 잡아야 코드가 읽힌다.

기존 JavaScript는 DOM을 직접 찾아서 고친다. 결과 영역의 글자를 “불러오는 중…“으로 바꾸라고 명령하는 식이다.

ts
result.innerText = "불러오는 중...";

React는 DOM을 어떻게 바꿀지 적지 않는다. 지금 상태라면 화면이 무엇이어야 하는지만 적는다. 이것이 선언형이라는 말의 뜻이다. 상태가 같으면 화면도 같으므로, 화면이 복잡해져도 상태만 따라가면 된다. 그래서 React 코드를 짤 때 가장 먼저 정할 것은 “어떤 값이 상태인가"다.

화면이 바뀌기까지의 흐름은 이렇다.

text
사용자 이벤트 (클릭, 입력)
  → event handler 실행
  → setState 호출
  → React가 컴포넌트 함수를 다시 실행 (리렌더)
  → 새 React element 트리 생성
  → 이전 트리와 비교해서 달라진 DOM만 반영

여기서 렌더링은 “화면에 그린다"보다 “컴포넌트 함수를 다시 실행해서 이번 UI를 계산한다"에 가깝다. 실제 DOM 반영은 그 계산 결과를 비교한 다음 일이다.

그래서 컴포넌트, element, DOM은 서로 다른 것이다.

text
Component = UI를 반환하는 함수            function Button() { ... }
Element   = React가 이해하는 UI 설명 객체   <Button />
DOM       = 브라우저에 실제로 생긴 노드      <button>조회</button>

이 구분을 해 두면 “JSX가 곧 DOM인가?” 같은 혼란이 줄어든다.

1.2 Components, JSX, and Props

컴포넌트는 UI를 반환하는 JavaScript 함수다. JSX는 JavaScript 안에서 UI 구조를 쓰는 문법이고, HTML처럼 보이지만 element를 만드는 문법에 가깝다.

tsx
function MetricCard({ name, value }: { name: string; value: number }) {
  return (
    <div className="card">
      <h2>{name}</h2>
      <p>{value}</p>
    </div>
  );
}

중괄호 {} 안은 다시 JavaScript 값이다. src="avatar"는 문자열 그대로이고 src={user.imageUrl}은 변수 값이다. HTML의 class 대신 className을 쓴다.

백엔드 개발자에게는 이렇게 대응시키면 편하다. 컴포넌트는 함수이고, props는 함수의 인자이고, return이 UI다. 같은 컴포넌트라도 인자가 다르면 다른 UI가 나온다.

tsx
<MetricCard name="DAU" value={12000} />
<MetricCard name="주문 수" value={3400} />

컴포넌트로 나누는 이유는 세 가지다. 복잡한 화면을 작은 단위로 나누고, 같은 UI를 재사용하고, 데이터 흐름을 추적하기 쉽게 만들기 위해서다.

1.3 Props vs State

가장 중요한 구분이다. props는 부모가 내려주는 값이고, state는 컴포넌트가 스스로 기억하는 값이다.

PropsState
누가 정하나부모가 내려준다컴포넌트가 스스로 기억한다
바꿀 수 있나자식은 못 바꾼다setState로 바꾼다
바뀌면부모가 다시 렌더링하며 새 값을 준다그 컴포넌트가 리렌더된다
tsx
function LikeButton({ label }: { label: string }) {
  const [liked, setLiked] = useState(false);
  return (
    <button onClick={() => setLiked(!liked)}>
      {label}: {liked ? "좋아요" : "아직"}
    </button>
  );
}

label은 부모가 준 props이고 liked는 LikeButton이 가진 state다. 판단 기준은 단순하다. 부모가 정하는 값이면 props, 이 컴포넌트가 직접 바꾸는 값이면 state, 여러 컴포넌트가 같이 써야 하면 공통 부모의 state나 전역 상태 후보다.

1.4 Events, Conditions, and Lists

이벤트 핸들러에는 함수 자체를 넘긴다.

tsx
<button onClick={handleClick}>검색</button>   // 클릭할 때 실행
<button onClick={handleClick()}>검색</button> // 렌더링할 때 바로 실행된다. 틀린 코드

두 번째 줄은 함수를 호출한 결과를 넘긴다. 렌더링이 함수 재실행이므로, 렌더링할 때마다 handleClick이 실행된다.

실제 서비스 화면은 대부분 로딩, 에러, 빈 결과, 성공의 네 상태로 갈라진다. 앞의 셋을 먼저 걸러내고 마지막에 정상 화면을 반환하면 읽기 쉽다. 백엔드에서 early return으로 예외 경로를 먼저 처리하는 것과 같다.

tsx
function Result({ isLoading, error, data }: Props) {
  if (isLoading) return <p>불러오는 중...</p>;
  if (error) return <p>에러 발생</p>;
  if (!data) return <p>결과 없음</p>;
  return <p>{data.length}개 결과</p>;
}

리스트는 map으로 배열의 각 항목을 element로 바꾼다.

tsx
{metrics.map((metric) => (
  <li key={metric.id}>{metric.name}</li>
))}

key는 React가 리스트 항목을 구분하는 값이다. 이전 트리와 비교할 때 어떤 항목이 추가, 삭제, 이동됐는지를 key로 판단한다. 그래서 DB id처럼 바뀌지 않는 값을 쓴다. 배열 index를 key로 쓰면 정렬, 삭제, 삽입할 때 state가 엉뚱한 항목에 붙을 수 있다.

1.5 Controlled Inputs

input의 값을 state가 쥐고 있는 방식을 controlled input이라고 한다.

tsx
function SearchBox() {
  const [keyword, setKeyword] = useState("");
  return <input value={keyword} onChange={(e) => setKeyword(e.target.value)} />;
}
text
input value는 state에서 온다
→ 사용자가 입력하면 onChange 발생
→ onChange에서 state 변경
→ 변경된 state가 다시 input value로 들어간다

입력 칸 하나라면 이걸로 충분하다. 그런데 칸이 수십 개가 되고, 칸마다 검증과 에러 표시와 “수정됐는가” 표시가 붙으면 state를 직접 관리하기가 버거워진다. 이 일을 대신하는 것이 다음 편에서 볼 react-hook-form이다. 폼을 제출할 때 브라우저가 기본으로 페이지를 새로고침하는 동작을 막으려면 event.preventDefault()를 쓴다.

1.6 Hooks and the Call-Order Rule

Hook은 함수형 컴포넌트에서 state와 effect 같은 React 기능을 쓰게 해 주는 함수다. 예전에는 이런 기능을 쓰려면 class 컴포넌트가 필요했다.

tsx
class Counter extends React.Component {
  state = { count: 0 };
  render() {
    return <button>{this.state.count}</button>;
  }
}

class 컴포넌트는 this와 lifecycle method 때문에 로직이 여기저기 흩어졌다. Hook이 생기면서 함수 안에서 state와 effect를 바로 쓸 수 있게 됐다.

Hook에는 규칙이 둘 있다. 이런 규칙이 생긴 이유는 React가 Hook을 이름이 아니라 호출 순서로 구분하기 때문이다. 첫 번째 useState, 두 번째 useEffect처럼 순번을 매겨 상태를 붙인다. 위치 인자처럼 순서가 곧 식별자인 셈이다.

  • 컴포넌트나 커스텀 Hook의 최상위에서만 호출한다. 조건문, 반복문, 중간 return 뒤에서 호출하면 렌더링마다 순서가 달라지고, 상태가 엉뚱한 Hook에 붙는다.
  • React 함수(컴포넌트, 커스텀 Hook) 안에서만 호출한다.

1.7 useState and useEffect

useState는 컴포넌트가 값을 기억하게 한다. 이전 값을 기준으로 바꿀 때는 함수형 업데이트를 쓴다. 그러면 같은 이벤트 안에서 여러 번 호출해도 안전하다.

tsx
const [count, setCount] = useState(0);
setCount((prev) => prev + 1);

useEffect는 렌더링 뒤에 React 바깥 시스템과 동기화할 때 쓴다. 타이머, 구독, 브라우저 API, 외부 위젯 같은 것이다. 실행 시점은 dependency array가 정한다.

text
useEffect(() => {})         매 렌더링 후 실행
useEffect(() => {}, [])     처음 한 번 실행
useEffect(() => {}, [date]) date가 바뀔 때 실행

정리할 일이 있으면 정리 함수를 반환한다.

tsx
useEffect(() => {
  const timerId = setInterval(() => console.log("tick"), 1000);
  return () => clearInterval(timerId);
}, []);

event handler와 effect는 실행 이유가 다르다. handler는 사용자가 무언가를 했기 때문에 실행되고, effect는 렌더링 결과를 바깥과 맞추기 위해 실행된다.

처음 공부할 때는 useEffect 안에서 fetch를 부르는 예제를 많이 봤다. 동작 원리를 이해하는 데는 좋지만 실제 코드에서는 피하는 편이 낫다. 공식 문서 “You Might Not Need an Effect"의 요지가 이것이다.

  • 서버 데이터 조회는 TanStack Query 같은 서버 상태 도구에 맡긴다.
  • props에서 계산할 수 있는 값은 state로 두지 말고 렌더링 중에 계산한다.

두 번째 항목이 특히 자주 걸린다. props가 바뀔 때 effect에서 state를 다시 맞추면, 렌더링이 한 번 더 일어나고 그 사이에 어긋난 값이 한 번 그려진다.

1.8 useRef, useMemo, useContext, Custom Hooks

useRef는 렌더링과 상관없는 값을 기억하거나 DOM에 접근할 때 쓴다. ref.current를 바꿔도 리렌더되지 않는다. 포커스를 줄 input, timer id, 이전 값, 외부 라이브러리 인스턴스 같은 것을 담아 둔다. 바뀌어도 화면이 따라 바뀌지 않는다는 성질이 3편에서 다시 문제가 된다.

tsx
const inputRef = useRef<HTMLInputElement>(null);
const focusInput = () => inputRef.current?.focus();
return <input ref={inputRef} />;

useMemo는 계산 결과를, useCallback은 함수 자체를 캐시한다. 계산이 비싸거나, memo된 자식에게 함수를 넘기거나, dependency로 쓰는 값의 참조가 안정적이어야 할 때 쓴다. React Compiler를 켠 프로젝트에서는 직접 쓸 일이 거의 없는데, 이것도 3편에서 다룬다.

tsx
const filteredMetrics = useMemo(
  () => metrics.filter((metric) => metric.name.includes(keyword)),
  [metrics, keyword],
);

useContext는 props를 여러 단계 내려주지 않고 깊은 컴포넌트에서 공통 값을 읽게 한다. 로그인한 사용자, 테마, 언어, 권한 같은 값을 전달하기에 알맞다. Context는 값 전달 통로이지 상태 관리 도구 자체는 아니다.

tsx
const UserContext = createContext<User | null>(null);

function App() {
  return (
    <UserContext.Provider value={{ name: "Haram" }}>
      <UserProfile />
    </UserContext.Provider>
  );
}

function UserProfile() {
  const user = useContext(UserContext);
  return <p>{user?.name}</p>;
}

커스텀 Hook은 use로 시작하는 함수에 Hook 조합을 묶은 것이다. 착각하기 쉬운 점이 하나 있다. 커스텀 Hook은 state를 공유하는 도구가 아니다. 여러 컴포넌트가 같은 커스텀 Hook을 부르면 각자 독립된 state를 가진다. 같은 클래스의 인스턴스를 여러 개 만든 것에 가깝다.

tsx
function useMetrics() {
  return useQuery({ queryKey: ["metrics"], queryFn: fetchMetrics });
}

1.9 Where State Should Live

상태를 어디에 둘지는 원본이 어디 있는지로 정한다.

종류원본도구
클라이언트 상태브라우저와 UI (모달이 열렸는지, 선택한 탭)useState, Zustand
서버 상태서버와 DB (목록, 설정, 권한)TanStack Query, SWR
폼 상태사용자가 입력 중인 폼react-hook-form

이 구분을 못 하면 서버에서 받은 데이터를 useState나 전역 store에 복사하게 된다. 그러면 캐시, 재조회, 로딩과 에러 상태를 전부 직접 관리해야 한다. 백엔드로 치면 DB 결과를 애플리케이션 메모리에 복사해 두고 무효화를 직접 처리하는 것과 같다.

TanStack Query에서 queryKey는 캐시 키다. 조회 조건이 바뀌면 키가 바뀌고 자동으로 다시 조회한다. 그래서 조건으로 쓰는 값은 전부 키에 넣는다. 저장과 수정은 useMutation으로 하고, 성공하면 관련 키를 무효화해 목록을 새로 받는다.

tsx
useQuery({
  queryKey: ["users", { page, limit, search }],
  queryFn: () => fetchUsers({ page, limit, search }),
});

여러 컴포넌트가 같은 클라이언트 상태를 쓸 때는 이 순서로 생각한다.

  1. 공통 부모로 올린다(lifting state up). 가장 먼저 해 볼 방법이다.
  2. 중간 컴포넌트가 쓰지도 않는 props를 계속 넘기게 되면(props drilling), 컴포넌트 구조를 먼저 바꿔 본다.
  3. 그래도 깊으면 Context를 쓴다. 단, 값이 바뀌면 그 값을 읽는 컴포넌트가 모두 리렌더된다.
  4. 앱 전역에서 자주 바뀌는 값이면 Redux, Recoil, Zustand 같은 전역 상태 도구를 쓴다.
tsx
function Dashboard() {
  const [date, setDate] = useState("2026-07-10");
  return (
    <>
      <DateFilter date={date} onChangeDate={setDate} />
      <Chart date={date} />
      <Table date={date} />
    </>
  );
}

Zustand는 create로 store를 만들고 Hook처럼 쓴다. selector로 필요한 값만 꺼내면 다른 값이 바뀌어도 리렌더되지 않는다.

ts
const useUserStore = create<UserStore>((set) => ({
  user: null,
  setUser: (user) => set({ user }),
}));

const user = useUserStore((state) => state.user);

1.10 Server and Client Components

React는 UI 라이브러리이고, Next.js는 그 위에 라우팅, 서버 렌더링, 빌드 같은 웹 서비스에 필요한 것을 얹은 프레임워크다.

Next.js App Router에서 app/ 아래 컴포넌트는 기본이 Server Component다. 서버에서 렌더링되므로 useState, useEffect, 이벤트 핸들러를 쓸 수 없다. 상태나 이벤트가 필요한 파일은 맨 위에 'use client'를 적어 Client Component로 만든다. 폼은 상태와 이벤트 덩어리이므로 거의 항상 Client Component다.

tsx
"use client";

export function Counter() {
  const [count, setCount] = useState(0);
  return <button onClick={() => setCount(count + 1)}>{count}</button>;
}

(components)처럼 괄호로 감싼 폴더는 URL에 들어가지 않는다. 라우트와 상관없이 파일을 묶는 용도다.

1.11 A Checklist Before Writing Code

API 서버 쪽 흐름까지 이어 보면 요청 하나가 이렇게 흐른다.

text
사용자 화면 조작
→ 컴포넌트 state 변경
→ 필요하면 API 요청
→ FastAPI가 요청을 받아 Pydantic으로 검증
→ SQLAlchemy로 DB 조회
→ JSON 응답
→ useQuery가 받아 캐시하고 화면을 다시 그림

React 코드를 짜기 전에 다음을 확인한다.

  1. 이 값은 상태인가? props나 다른 상태에서 계산할 수 있으면 상태가 아니다.
  2. 상태라면 원본이 어디인가? 서버 데이터면 서버 상태 도구, 폼 입력이면 react-hook-form, UI 상태면 useState나 전역 store다.
  3. 상태나 이벤트 핸들러를 쓰는 파일 맨 위에 'use client'를 적었는가?
  4. useEffect를 쓰려고 한다면, 정말 React 바깥 시스템과 동기화하는 일인가?
  5. 리스트에 안정적인 key를 줬는가?
  6. Hook을 조건문이나 반복문 안에서 부르지 않았는가?
Discussion