React 제어 컴포넌트 폼과 상태 끌어올리기 첫 실습 가이드

2026.09.05·수정 2026.09.12·약 34분·작성: 해비·블로그 소개

이 글에서 완성하는 것

React 제어 컴포넌트 폼에서 입력값을 부모 state 하나로 관리하고, 빈 이름 제출을 막으며, 형제 미리보기가 같은 값을 즉시 표시하도록 구현합니다.

먼저 읽기: React useState 사용법: state와 객체 배열 업데이트 기준 · TypeScript 기본 타입: 선택 학습 · JavaScript DOM 폼 만들기: 입력 검증부터 완료 삭제까지

완성 결과와 상태 소유권

폼에는 이름 input과 역할 select가 있습니다. 입력할 때마다 화면의 미리보기도 바뀝니다. 내용 확인 버튼을 눌렀을 때 이름이 비어 있으면 경고가 나오고, 값이 있으면 확인 메시지가 나옵니다. 브라우저 DOM을 직접 수정해 미리보기를 맞추는 대신 React state가 입력과 출력의 공통 근거가 됩니다.

제어 입력은 value를 state에서 받고 onChange에서 그 state를 갱신합니다. value만 전달하고 onChange를 빠뜨리면 사용자가 친 문자가 state에 반영되지 않아 입력이 되돌아옵니다. 처음부터 문자열로 초기화하면 컴포넌트의 생명 동안 제어 방식도 일관됩니다.

빈 종이가 놓인 나무 클립보드와 파란 연필
빈 종이가 놓인 나무 클립보드와 파란 연필입니다. 실제 동작은 예제 코드에서 확인합니다.

프로젝트 준비와 파일 구조

ZIP을 풀고 starter 또는 complete 폴더에서 npm install을 실행합니다. Node.js는 22.12 이상을 사용합니다. 이 예제는 React 19.3.0, Vite 8.3.0 조합으로 작성했습니다. App은 상태를 소유하고, ProfileForm은 변경 이벤트를 전달하며, ProfileSummary는 읽기만 합니다.

npm run dev로 개발 서버를 열고 표시된 로컬 주소에 접속합니다. 전체 실행 파일은 아래 실습 ZIP에 포함되어 있습니다. profile 객체의 name과 role을 폼과 요약이 함께 읽습니다.

제어 입력과 submit 구현

폼 제출은 브라우저의 기본 페이지 이동을 preventDefault()로 막고, 제출 시도 여부를 기록합니다. 이름은 trim()한 결과가 비었는지 검사하므로 공백만 입력한 경우도 오류입니다. noValidate는 이 실습에서 브라우저 기본 말풍선보다 React로 만든 오류 상태를 관찰하기 위해 둡니다.

입력 변경 시 { ...value, name: e.target.value }처럼 다른 필드는 보존하고 이름만 바꿉니다. 이전 오류가 남아 혼동하지 않도록 이름 입력이나 역할 선택이 바뀌면 submitted를 false로 되돌립니다. 그렇지 않으면 바꾼 역할까지 확인한 것처럼 이전 성공 안내가 남습니다. 오류에는 role="alert"와 고유 id를 두고 input의 aria-invalid 및 aria-describedby로 연결합니다. 성공 안내에는 role="status"를 지정해 상태의 의미도 DOM에 남깁니다.

공용 트레이 양쪽에 놓인 두 개의 카드 받침
공용 트레이 양쪽에 놓인 두 개의 카드 받침입니다. 실제 동작은 예제 코드에서 확인합니다.

상태 끌어올리기와 공유 요약

폼과 요약이 각자 profile state를 가지면 한쪽을 바꿀 때 다른 쪽을 별도로 동기화해야 합니다. 두 컴포넌트의 가장 가까운 공통 부모인 App으로 값을 옮기면 소유자는 하나가 됩니다. 부모는 현재 값과 변경 함수를 폼에 전달하고, 현재 값만 요약에 전달합니다. 이를 상태 끌어올리기라고 합니다.

자식이 부모 state를 직접 수정하는 것은 아닙니다. ProfileForm은 다음 profile 객체를 만들어 onChange를 호출하고, 부모의 setProfile이 다음 렌더를 예약합니다. 다음 렌더에서 두 자식은 동일한 profile props를 받습니다. 따라서 별도의 Effect로 값을 복사할 이유가 없습니다.

상태 위치를 정할 때는 그 값을 읽는 모든 컴포넌트를 먼저 표시해 보세요. 한 자식만 쓰는 값이라면 그 자식에 두는 편이 단순합니다. 폼과 요약처럼 둘 이상이 함께 읽거나 바꿔야 하면 가장 가까운 공통 부모가 후보가 됩니다. 너무 높은 루트로 올리면 관련 없는 화면까지 props를 전달해야 하므로 필요한 범위까지만 올립니다.

제출 검증과 입력 검증의 시점도 구분합니다. 이 예제는 사용자가 내용 확인을 시도한 뒤 오류를 보여 주어 빈 초기 화면이 곧바로 경고로 채워지지 않게 했습니다. 제품 규칙에 따라 blur 시점이나 입력 중 검증으로 바꿀 수 있지만, 오류를 계산하는 근거는 여전히 현재 state입니다. error 자체가 name에서 바로 계산되는 값이므로 별도의 state로 저장하지 않아도 됩니다.

여러 필드를 갱신할 때 이전 객체를 직접 수정하면 안 됩니다. spread로 새 객체를 만들면 React가 다음 값의 변경을 명확하게 다룰 수 있고, 역할을 바꿀 때 이름이 사라지거나 이름을 바꿀 때 역할이 초기화되는 실수도 피할 수 있습니다. 필드가 많아지면 공통 change handler나 reducer를 검토할 수 있으나 이 실습은 이름·역할 이벤트에서 changeField를 명시적으로 호출해 변경 필드를 읽기 쉽게 했습니다.

전체 프로젝트 파일과 역할

다운로드는 index.html, src/main.jsx, src/App.jsx, src/ProfileForm.jsx, src/ProfileSummary.jsx, src/style.css와 Vitest 설정·테스트를 포함합니다. 아래는 폼과 요약의 완성 코드입니다. JSX로 제공하므로 이 첫 실습에는 TypeScript 타입 설정이 필요하지 않습니다. React 19.3.0·Vite 8.3.0을 package.json에 고정했습니다.

src/ProfileForm.jsx

실습 파일

파일 경로를 확인하고 같은 프로젝트 안에 저장하세요. 이미지 등 소스 목록에 없는 파일과 실행 안내는 실습 ZIP에 포함되어 있습니다.

입력값과 변경 이벤트를 연결해 부모가 폼 상태를 관리하도록 구성합니다. 수정 전은 시작 상태, 수정 후는 비교할 완성 상태입니다.

수정 전

index.html

<!doctype html>
<html lang="ko">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>React 실습</title>
  </head>
  <body>
    <div id="root"></div>
    <script type="module" src="/src/main.jsx"></script>
  </body>
</html>

package.json

{
  "name": "blogflow-react-8812",
  "version": "1.0.0",
  "private": true,
  "type": "module",
  "engines": {
    "node": ">=22.12.0"
  },
  "scripts": {
    "dev": "vite",
    "build": "vite build",
    "preview": "vite preview",
    "test": "vitest run"
  },
  "dependencies": {
    "react": "19.3.0",
    "react-dom": "19.3.0"
  },
  "devDependencies": {
    "vite": "8.3.0",
    "vitest": "4.1.11",
    "jsdom": "30.0.1",
    "@testing-library/react": "16.3.3",
    "@testing-library/user-event": "14.6.7"
  }
}

src/App.jsx

import React, { useState } from 'react';
import { ProfileForm } from './ProfileForm.jsx';
import { ProfileSummary } from './ProfileSummary.jsx';

export default function App() {
  const [profile, setProfile] = useState({ name: '', role: 'frontend' });
  return (
    <main>
      <h1>프로필 작성</h1>
      <ProfileForm value={profile} onChange={setProfile} />
      <ProfileSummary profile={profile} />
    </main>
  );
}

src/ProfileForm.jsx

import React, { useState } from 'react';

export function ProfileForm({ value, onChange }) {
  const [submitted, setSubmitted] = useState(false);
  const error = value.name.trim() === '' ? '이름을 입력해 주세요.' : '';
  const showError = submitted && Boolean(error);

  function changeField(field, nextValue) {
    onChange({ ...value, [field]: nextValue });
  }
  function handleSubmit(event) {
    event.preventDefault();
    setSubmitted(true);
  }
  return (
    <form onSubmit={handleSubmit} noValidate>
      <label htmlFor="name">이름</label>
      <input
        id="name"
        value={value.name}
        onChange={(event) => changeField('name', event.target.value)}
      />
      <label htmlFor="role">역할</label>
      <select
        id="role"
        value={value.role}
        onChange={(event) => changeField('role', event.target.value)}
      >
        <option value="frontend">프런트엔드</option>
        <option value="backend">백엔드</option>
      </select>
      {showError && (
        <p id="name-error" role="alert">
          {error}
        </p>
      )}
      <button type="submit">내용 확인</button>
      {submitted && !error && <p role="status">입력 내용을 확인했습니다.</p>}
    </form>
  );
}

src/ProfileSummary.jsx

import React from 'react';

export function ProfileSummary({ profile }) {
  const roleLabel = profile.role === 'frontend' ? '프런트엔드' : '백엔드';
  return (
    <section aria-label="프로필 요약">
      <h2>미리보기</h2>
      <p>
        {profile.name.trim() || '이름 없음'} · {roleLabel}
      </p>
    </section>
  );
}

src/main.jsx

import React, { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import App from './App.jsx';
import './style.css';

createRoot(document.getElementById('root')).render(
  <StrictMode>
    <App />
  </StrictMode>,
);

src/style.css

* {
  box-sizing: border-box;
}
body {
  margin: 0;
  color: #222;
  background: #fff;
  font-family: sans-serif;
  line-height: 1.6;
}
main {
  width: min(100% - 32px, 760px);
  margin: 32px auto;
}
section,
article {
  padding: 16px;
  border: 1px solid #aaa;
  margin: 16px 0;
}
button,
input,
select {
  font: inherit;
  color: inherit;
  background: #fff;
  border: 1px solid #777;
  padding: 8px 12px;
  min-height: 44px;
}
button {
  margin: 4px;
}
button:disabled {
  color: #666;
  background: #eee;
}
input,
select {
  display: block;
  width: 100%;
  margin: 4px 0 16px;
}
:focus-visible {
  outline: 2px solid #222;
  outline-offset: 3px;
}
li {
  margin: 8px 0;
}

test/App.test.jsx

import React from 'react';
import { test, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import App from '../src/App.jsx';

test('validates blank names and shares the current profile with the sibling', async () => {
  const user = userEvent.setup();
  render(<App />);
  const input = screen.getByLabelText('이름');
  await user.click(screen.getByRole('button', { name: '내용 확인' }));
  expect(screen.getByRole('alert').textContent).toBe('이름을 입력해 주세요.');
  expect(input.getAttribute('aria-describedby')).toBe('name-error');
  expect(input.getAttribute('aria-invalid')).toBe('true');
  await user.type(input, '   ');
  await user.click(screen.getByRole('button', { name: '내용 확인' }));
  expect(screen.getByRole('alert')).toBeTruthy();
  await user.clear(input);
  await user.type(input, '민지');
  expect(screen.getByRole('region', { name: '프로필 요약' }).textContent).toContain(
    '민지 · 프런트엔드',
  );
  await user.click(screen.getByRole('button', { name: '내용 확인' }));
  expect(screen.getByRole('status').textContent).toBe('입력 내용을 확인했습니다.');
  await user.selectOptions(screen.getByLabelText('역할'), 'backend');
  expect(screen.queryByRole('status')).toBeNull();
  expect(screen.getByRole('region', { name: '프로필 요약' }).textContent).toContain(
    '민지 · 백엔드',
  );
});

test/setup.js

import { afterEach } from 'vitest';
import { cleanup } from '@testing-library/react';
afterEach(cleanup);

vite.config.js

import { defineConfig } from 'vite';

export default defineConfig({
  test: { environment: 'jsdom', setupFiles: './test/setup.js' },
});

수정 방법

index.html

이 파일은 변경하지 않습니다. 다른 파일을 수정하는 동안 기존 내용을 유지하세요.

package.json

이 파일은 변경하지 않습니다. 다른 파일을 수정하는 동안 기존 내용을 유지하세요.

src/App.jsx

이 파일은 변경하지 않습니다. 다른 파일을 수정하는 동안 기존 내용을 유지하세요.

src/ProfileForm.jsx

src/ProfileForm.jsx에서 아래 차이가 있는 위치를 수정하세요. -는 삭제할 줄, +는 추가할 줄이며 표시는 실제 코드에 넣지 않습니다.

입력값과 변경 이벤트를 연결해 부모가 폼 상태를 관리하도록 구성합니다.

--- 수정 전/src/ProfileForm.jsx
+++ 수정 후/src/ProfileForm.jsx
@@ -6,6 +6,7 @@
   const showError = submitted && Boolean(error);
 
   function changeField(field, nextValue) {
+    setSubmitted(false);
     onChange({ ...value, [field]: nextValue });
   }
   function handleSubmit(event) {
@@ -18,6 +19,8 @@
       <input
         id="name"
         value={value.name}
+        aria-invalid={showError}
+        aria-describedby={showError ? 'name-error' : undefined}
         onChange={(event) => changeField('name', event.target.value)}
       />
       <label htmlFor="role">역할</label>

수정 후 탭의 전체 파일과 비교한 뒤 본문의 확인 절차를 실행하세요.

src/ProfileSummary.jsx

이 파일은 변경하지 않습니다. 다른 파일을 수정하는 동안 기존 내용을 유지하세요.

src/main.jsx

이 파일은 변경하지 않습니다. 다른 파일을 수정하는 동안 기존 내용을 유지하세요.

src/style.css

이 파일은 변경하지 않습니다. 다른 파일을 수정하는 동안 기존 내용을 유지하세요.

test/App.test.jsx

이 파일은 변경하지 않습니다. 다른 파일을 수정하는 동안 기존 내용을 유지하세요.

test/setup.js

이 파일은 변경하지 않습니다. 다른 파일을 수정하는 동안 기존 내용을 유지하세요.

vite.config.js

이 파일은 변경하지 않습니다. 다른 파일을 수정하는 동안 기존 내용을 유지하세요.

수정 후

index.html

<!doctype html>
<html lang="ko">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>React 실습</title>
  </head>
  <body>
    <div id="root"></div>
    <script type="module" src="/src/main.jsx"></script>
  </body>
</html>

package.json

{
  "name": "blogflow-react-8812",
  "version": "1.0.0",
  "private": true,
  "type": "module",
  "engines": {
    "node": ">=22.12.0"
  },
  "scripts": {
    "dev": "vite",
    "build": "vite build",
    "preview": "vite preview",
    "test": "vitest run"
  },
  "dependencies": {
    "react": "19.3.0",
    "react-dom": "19.3.0"
  },
  "devDependencies": {
    "vite": "8.3.0",
    "vitest": "4.1.11",
    "jsdom": "30.0.1",
    "@testing-library/react": "16.3.3",
    "@testing-library/user-event": "14.6.7"
  }
}

src/App.jsx

import React, { useState } from 'react';
import { ProfileForm } from './ProfileForm.jsx';
import { ProfileSummary } from './ProfileSummary.jsx';

export default function App() {
  const [profile, setProfile] = useState({ name: '', role: 'frontend' });
  return (
    <main>
      <h1>프로필 작성</h1>
      <ProfileForm value={profile} onChange={setProfile} />
      <ProfileSummary profile={profile} />
    </main>
  );
}

src/ProfileForm.jsx

import React, { useState } from 'react';

export function ProfileForm({ value, onChange }) {
  const [submitted, setSubmitted] = useState(false);
  const error = value.name.trim() === '' ? '이름을 입력해 주세요.' : '';
  const showError = submitted && Boolean(error);

  function changeField(field, nextValue) {
    setSubmitted(false);
    onChange({ ...value, [field]: nextValue });
  }
  function handleSubmit(event) {
    event.preventDefault();
    setSubmitted(true);
  }
  return (
    <form onSubmit={handleSubmit} noValidate>
      <label htmlFor="name">이름</label>
      <input
        id="name"
        value={value.name}
        aria-invalid={showError}
        aria-describedby={showError ? 'name-error' : undefined}
        onChange={(event) => changeField('name', event.target.value)}
      />
      <label htmlFor="role">역할</label>
      <select
        id="role"
        value={value.role}
        onChange={(event) => changeField('role', event.target.value)}
      >
        <option value="frontend">프런트엔드</option>
        <option value="backend">백엔드</option>
      </select>
      {showError && (
        <p id="name-error" role="alert">
          {error}
        </p>
      )}
      <button type="submit">내용 확인</button>
      {submitted && !error && <p role="status">입력 내용을 확인했습니다.</p>}
    </form>
  );
}

src/ProfileSummary.jsx

import React from 'react';

export function ProfileSummary({ profile }) {
  const roleLabel = profile.role === 'frontend' ? '프런트엔드' : '백엔드';
  return (
    <section aria-label="프로필 요약">
      <h2>미리보기</h2>
      <p>
        {profile.name.trim() || '이름 없음'} · {roleLabel}
      </p>
    </section>
  );
}

src/main.jsx

import React, { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import App from './App.jsx';
import './style.css';

createRoot(document.getElementById('root')).render(
  <StrictMode>
    <App />
  </StrictMode>,
);

src/style.css

* {
  box-sizing: border-box;
}
body {
  margin: 0;
  color: #222;
  background: #fff;
  font-family: sans-serif;
  line-height: 1.6;
}
main {
  width: min(100% - 32px, 760px);
  margin: 32px auto;
}
section,
article {
  padding: 16px;
  border: 1px solid #aaa;
  margin: 16px 0;
}
button,
input,
select {
  font: inherit;
  color: inherit;
  background: #fff;
  border: 1px solid #777;
  padding: 8px 12px;
  min-height: 44px;
}
button {
  margin: 4px;
}
button:disabled {
  color: #666;
  background: #eee;
}
input,
select {
  display: block;
  width: 100%;
  margin: 4px 0 16px;
}
:focus-visible {
  outline: 2px solid #222;
  outline-offset: 3px;
}
li {
  margin: 8px 0;
}

test/App.test.jsx

import React from 'react';
import { test, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import App from '../src/App.jsx';

test('validates blank names and shares the current profile with the sibling', async () => {
  const user = userEvent.setup();
  render(<App />);
  const input = screen.getByLabelText('이름');
  await user.click(screen.getByRole('button', { name: '내용 확인' }));
  expect(screen.getByRole('alert').textContent).toBe('이름을 입력해 주세요.');
  expect(input.getAttribute('aria-describedby')).toBe('name-error');
  expect(input.getAttribute('aria-invalid')).toBe('true');
  await user.type(input, '   ');
  await user.click(screen.getByRole('button', { name: '내용 확인' }));
  expect(screen.getByRole('alert')).toBeTruthy();
  await user.clear(input);
  await user.type(input, '민지');
  expect(screen.getByRole('region', { name: '프로필 요약' }).textContent).toContain(
    '민지 · 프런트엔드',
  );
  await user.click(screen.getByRole('button', { name: '내용 확인' }));
  expect(screen.getByRole('status').textContent).toBe('입력 내용을 확인했습니다.');
  await user.selectOptions(screen.getByLabelText('역할'), 'backend');
  expect(screen.queryByRole('status')).toBeNull();
  expect(screen.getByRole('region', { name: '프로필 요약' }).textContent).toContain(
    '민지 · 백엔드',
  );
});

test/setup.js

import { afterEach } from 'vitest';
import { cleanup } from '@testing-library/react';
afterEach(cleanup);

vite.config.js

import { defineConfig } from 'vite';

export default defineConfig({
  test: { environment: 'jsdom', setupFiles: './test/setup.js' },
});

src/ProfileForm.jsx 전체 코드 보기

src/ProfileSummary.jsx

src/ProfileSummary.jsx 전체 코드 보기

실행과 검증

npm run build는 JSX를 프로덕션 번들로 만듭니다. 타입 검사는 포함하지 않습니다. npm test는 jsdom에서 빈 이름·공백 이름 오류, 형제 미리보기, 내용 확인 성공과 역할 변경 후 성공 해제를 검사합니다.

  1. 이름이 빈 상태로 제출해 오류 문구를 확인합니다.
  2. 이름을 입력해 미리보기가 같은 글자로 즉시 바뀌는지 확인합니다.
  3. 역할을 바꿔 요약의 역할도 함께 바뀌는지 확인합니다.
  4. 다시 내용을 확인해 성공 상태가 표시되는지 확인합니다.

React 공식 문서의 input 제어 방법상태 공유 안내에서 같은 원칙을 확인할 수 있습니다. 완료 기준은 입력값의 소유자가 부모 하나이고, 폼과 요약이 그 값을 서로 다르게 복사하지 않는 것입니다.

현재 입력 확인과 저장의 경계

이 예제는 이름을 trim한 결과가 비어 있는지 확인하며 서버나 localStorage에는 저장하지 않습니다. 따라서 버튼은 “내용 확인”으로 표시합니다. 입력을 바꾸면 이전 확인 결과를 지우며 새로고침하면 이름·역할이 초기값으로 돌아갑니다. 미리보기는 입력 중 값을 즉시 보여 주고 확인 결과는 제출 뒤만 나타납니다. 이 두 시점을 구분하는 것이 과제의 핵심입니다.

직접 실행할 실습

ProfileForm의 changeField에서 입력 변경마다 submitted를 false로 되돌리세요. 빈 이름 오류를 input의 aria-invalid와 aria-describedby에 연결하세요. 성공 후 역할 변경 시 성공 문구가 사라져야 합니다.

아래는 완성본의 src/App.jsx입니다. ZIP에는 별도 HTML·CSS, 진입점, 필요한 자식 컴포넌트, 고정 패키지 버전과 테스트가 함께 들어 있습니다. starter는 미완성 동작을 수정하는 시작본이고 complete는 비교용 완성본입니다.

src/App.jsx 전체 코드 보기

기대 결과: 빈 제출과 공백 이름 제출은 오류. 민지 입력 시 형제 미리보기 변경. 내용 확인 후 성공 안내. 역할을 백엔드로 변경하면 성공 안내 해제 및 미리보기 갱신. 새로고침하면 초기화됩니다.

압축을 풀고 complete에서 npm installnpm run dev를 실행합니다. 빌드·검증은 별도 터미널에서 npm test, npm run build로 실행합니다. 자세한 시작본 과제와 파일 설명은 README에 있습니다.

시작·완성 실습 ZIP 내려받기

실습의 공식 참고 문서

이 글이 도움이 되었나요?

조회 중

React 학습 순서

필수 19개 · 전체 23개

읽음 기록 관리

전체 과정 목차 (23개)
  1. 필수 길잡이 · React 학습 순서: 컴포넌트·props·state부터 상태관리까지
  2. 필수 학습 · React Vite 사용법: Vite 8 프로젝트 생성·실행·빌드
  3. 필수 학습 · React 컴포넌트 개념 정리: UI 재사용 구조 잡기
  4. 필수 학습 · React JSX 문법 사용법: 조건부 렌더링과 리스트 처리
  5. 필수 학습 · React props 사용법: 부모에서 자식으로 데이터 전달하는 구조
  6. 필수 학습 · React useState 사용법: state와 객체 배열 업데이트 기준
  7. 선택 참고 · React state 업데이트 안됨 문제 해결
  8. 필수 선수 · React 리스트 key 경고 해결 기준: index key를 피해야 하는 이유
  9. 필수 학습 · React 제어 컴포넌트 폼과 상태 끌어올리기 첫 실습 가이드 현재 글
  10. 필수 학습 · React useRef 실습: 입력 포커스와 state의 역할 나누기
  11. 필수 선수 · React useEffect 두 번 실행되는 이유: StrictMode·API 중복 해결
  12. 선택 참고 · React Maximum update depth exceeded 오류 해결: 무한 렌더링 원인 찾기
  13. 선택 참고 · React Cannot update 오류 해결: 렌더링 중 setState 원인
  14. 필수 학습 · React useReducer와 Context 실습: 작업 목록 상태를 여러 컴포넌트에서 공유하기
  15. 필수 학습 · React 컴포넌트 props 타입 지정하기: 부모와 자식 사이의 값 구조 잡기
  16. 선택 참고 · React Hook Form 에러 메시지 표시 문제 해결: validation이 안 보일 때 체크리스트
  17. 필수 길잡이 · React 라이브러리 조합 가이드: Zustand·TanStack Query·shadcn/ui 선택 기준
  18. 필수 학습 · Next.js 커스텀 훅 설계: 프론트엔드 상태 관리 구조 잡기
  19. 필수 학습 · React Compiler 기준: useMemo useCallback 언제 줄일까
  20. 필수 학습 · React·TypeScript 검색 필터 만들기: 상태와 결과 목록 연결
  21. 필수 학습 · React Router v7 실습: BrowserRouter부터 Layout·Outlet·상세 경로까지
  22. 필수 학습 · shadcn/ui 시작 실습: Vite 설정·components.json·입력 폼·Sonner
  23. 필수 학습 · shadcn/ui 복합 컴포넌트 실습: Dialog·Popover·Carousel과 접근성

새 글 받아보기

RSS 리더에서 BlogFlow의 새 글을 확인할 수 있습니다.

RSS 피드 구독하기

댓글 남기기