React useReducer와 Context 실습: 작업 목록 상태를 여러 컴포넌트에서 공유하기

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

이번 실습의 목표

작업 추가·완료 토글·삭제 규칙을 순수 reducer로 모으고, 폼·요약·목록이 같은 데이터를 읽는 작업 보드를 만듭니다. 입력 중인 문자열은 폼에 남기고, 공유가 필요한 작업 배열만 Provider에서 관리합니다.

사용자 동작을 dispatch로 전달하면 reducer가 다음 상태를 반환합니다. Context를 통해 여러 컴포넌트가 상태와 dispatch를 함께 사용합니다.
사용자 동작을 dispatch로 전달하면 reducer가 다음 상태를 반환합니다. Context를 통해 여러 컴포넌트가 상태와 dispatch를 함께 사용합니다.

useState 다음에 생기는 상태 변경 규칙의 문제

작업 목록에는 새 작업을 넣는 규칙, 완료 여부를 바꾸는 규칙, 특정 작업을 지우는 규칙이 있습니다. 한 화면에서 useState로 모두 구현해도 정상입니다. 다만 여러 이벤트에 배열 업데이트 코드가 흩어지면 어떤 변경이 가능한지 찾기 어려워집니다. useReducer는 현재 배열과 무슨 일이 일어났는지를 받아 다음 배열을 계산하는 함수를 한곳에 둡니다.

Context는 다른 문제를 해결합니다. 폼은 변경 요청을 보내고, 요약은 개수를 읽고, 목록은 읽기와 변경을 모두 수행합니다. 이 컴포넌트들 사이에서 필요한 값을 여러 단계 props로 통과시키는 대신 공통 상위 Provider 아래에서 전달합니다. reducer만 써도 되고 Context만 써도 됩니다. 이번 예제는 두 도구의 책임을 분리해서 익히기 위해 함께 사용합니다.

입력 중인 문자열과 오류는 AddTask에만 필요하므로 로컬 state입니다. 작업 배열은 목록과 요약이 함께 사용하므로 Provider의 reducer state입니다. 전체 개수와 완료 개수는 배열에서 계산하며 따로 저장하지 않습니다. 서버 요청, 로그인 사용자, 영구 보관 기능은 포함하지 않습니다. 화면을 새로고침하면 초기 작업 두 개로 돌아갑니다.

컴포넌트 읽는 값 보내는 action
AddTask 자신의 입력 state added
TaskSummary 작업 배열 없음
TaskList 작업 배열 toggled, deleted

파일 준비와 실행

다운로드 파일은 HTML, CSS, JSX, 테스트를 분리한 완성 프로젝트입니다. 빈 폴더에 압축을 풀고 package.json이 있는 위치에서 명령을 실행합니다. Node.js는 Vite 8을 실행할 수 있는 22.12 이상 버전을 사용하세요. 이 실습에서는 React 19.3.0, Vite 8.3.0, Vitest 4.1.11을 사용했습니다. 다른 기존 프로젝트에 합칠 때에는 기존 의존성을 무작정 교체하지 말고 실습 폴더를 별도로 실행하세요.

실습 전체 코드 ZIP 다운로드

npm install
npm run dev
npm test
npm run build

개발 서버가 출력한 주소를 브라우저로 열면 됩니다. npm test는 테스트를 한 번 실행하고 종료하며 npm run build는 배포용 dist 폴더를 생성합니다. 빌드는 서버 배포나 데이터 영구 저장까지 수행하지 않습니다. 의존성 설치에는 패키지 레지스트리 접근이 필요합니다.

package.json

실습 파일

파일 경로를 확인하고 같은 프로젝트 안에 저장하세요. 이미지 등 소스 목록에 없는 파일과 실행 안내는 실습 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": "react-usereducer-context-state-practice",
  "version": "1.0.0",
  "private": true,
  "type": "module",
  "scripts": {
    "dev": "vite --host 0.0.0.0",
    "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 { TasksProvider, useTasks, useTasksDispatch } from './TasksContext.jsx';
function AddTask() {
  const [text, setText] = useState('');
  const [error, setError] = useState('');
  const dispatch = useTasksDispatch();
  function handleSubmit(event) {
    event.preventDefault();
    if (!text.trim()) {
      setError('작업 내용을 입력하세요.');
      return;
    }
    dispatch({ type: 'added', id: crypto.randomUUID(), text });
    setText('');
    setError('');
  }
  return (
    <form onSubmit={handleSubmit} noValidate>
      <label htmlFor="new-task">새 작업</label>
      <input
        id="new-task"
        value={text}
        aria-invalid={Boolean(error)}
        aria-describedby={error ? 'task-error' : undefined}
        onChange={(event) => {
          setText(event.target.value);
          setError('');
        }}
      />
      <button type="submit">작업 추가</button>
      {error && (
        <p id="task-error" role="alert">
          {error}
        </p>
      )}
    </form>
  );
}
function TaskSummary() {
  const tasks = useTasks();
  const completed = tasks.filter((task) => task.done).length;
  return (
    <p role="status">
      전체 {tasks.length}개 · 완료 {completed}개
    </p>
  );
}
function TaskList() {
  const tasks = useTasks();
  const dispatch = useTasksDispatch();
  if (!tasks.length) return <p>등록된 작업이 없습니다.</p>;
  return (
    <ul aria-label="작업 목록">
      {tasks.map((task) => (
        <li key={task.id}>
          <label>
            <input
              type="checkbox"
              checked={task.done}
              onChange={() => dispatch({ type: 'toggled', id: task.id })}
            />
            {task.text}
          </label>
          <button
            type="button"
            aria-label={`${task.text} 삭제`}
            onClick={() => dispatch({ type: 'deleted', id: task.id })}
          >
            삭제
          </button>
        </li>
      ))}
    </ul>
  );
}
export default function App() {
  return (
    <TasksProvider>
      <main>
        <h1>오늘의 작업</h1>
        <p>추가한 작업은 이 화면을 열어 둔 동안에만 유지됩니다.</p>
        <AddTask />
        <TaskSummary />
        <TaskList />
      </main>
    </TasksProvider>
  );
}

src/App.test.jsx

import React, { StrictMode } from 'react';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import App from './App.jsx';
test('추가·토글·삭제가 목록과 요약에 함께 반영된다', async () => {
  const user = userEvent.setup();
  render(
    <StrictMode>
      <App />
    </StrictMode>,
  );
  expect(screen.getByRole('status').textContent).toBe('전체 2개 · 완료 1개');
  await user.type(screen.getByRole('textbox', { name: '새 작업' }), '  배포 확인  ');
  await user.click(screen.getByRole('button', { name: '작업 추가' }));
  const checkbox = screen.getByRole('checkbox', { name: '배포 확인' });
  expect(screen.getByRole('status').textContent).toBe('전체 3개 · 완료 1개');
  await user.click(checkbox);
  expect(checkbox.checked).toBe(true);
  expect(screen.getByRole('status').textContent).toBe('전체 3개 · 완료 2개');
  await user.click(screen.getByRole('button', { name: '배포 확인 삭제' }));
  expect(screen.queryByRole('checkbox', { name: '배포 확인' })).toBeNull();
  expect(screen.getByRole('status').textContent).toBe('전체 2개 · 완료 1개');
});
test('빈 제출을 막고 모든 작업 삭제 후 빈 화면을 안내한다', async () => {
  const user = userEvent.setup();
  render(<App />);
  await user.click(screen.getByRole('button', { name: '작업 추가' }));
  expect(screen.getByRole('alert').textContent).toContain('입력');
  await user.click(screen.getByRole('button', { name: '요구사항 읽기 삭제' }));
  await user.click(screen.getByRole('button', { name: '예제 실행하기 삭제' }));
  expect(screen.getByText('등록된 작업이 없습니다.')).toBeTruthy();
  expect(screen.getByRole('status').textContent).toBe('전체 0개 · 완료 0개');
});

src/TasksContext.jsx

import React, { createContext, useContext, useReducer } from 'react';
import { initialTasks, tasksReducer } from './tasks.js';
const TasksContext = createContext(null);
const TasksDispatchContext = createContext(null);
export function TasksProvider({ children }) {
  const [tasks, dispatch] = useReducer(tasksReducer, initialTasks);
  return (
    <TasksContext.Provider value={tasks}>
      <TasksDispatchContext.Provider value={dispatch}>
        {children}
      </TasksDispatchContext.Provider>
    </TasksContext.Provider>
  );
}
export function useTasks() {
  const tasks = useContext(TasksContext);
  if (tasks === null) throw new Error('TasksProvider 안에서 useTasks를 사용하세요.');
  return tasks;
}
export function useTasksDispatch() {
  const dispatch = useContext(TasksDispatchContext);
  if (dispatch === null)
    throw new Error('TasksProvider 안에서 useTasksDispatch를 사용하세요.');
  return dispatch;
}

src/main.jsx

import React, { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import App from './App.jsx';
import './styles.css';
createRoot(document.getElementById('root')).render(
  <StrictMode>
    <App />
  </StrictMode>,
);

src/styles.css

* {
  box-sizing: border-box;
}
body {
  margin: 0;
  color: #222;
  background: #fff;
  font-family: sans-serif;
}
main {
  width: min(100% - 32px, 720px);
  margin: 40px auto;
}
form,
.actions {
  display: grid;
  gap: 12px;
}
input,
button {
  min-height: 44px;
  padding: 10px;
  font: inherit;
  border: 1px solid #777;
  background: #fff;
  color: #222;
}
button {
  cursor: pointer;
}
input:not([type='checkbox']) {
  width: 100%;
}
input:focus-visible,
button:focus-visible {
  outline: 3px solid #333;
  outline-offset: 3px;
}
ul {
  padding: 0;
  list-style: none;
  display: grid;
  gap: 12px;
}
li {
  border: 1px solid #aaa;
  padding: 16px;
  display: flex;
  gap: 12px;
  align-items: center;
  flex-wrap: wrap;
}
li label {
  flex: 1;
  display: flex;
  gap: 12px;
  align-items: center;
  overflow-wrap: anywhere;
}
p {
  line-height: 1.7;
}
.error {
  font-weight: 700;
}

src/tasks.js

export const initialTasks = [
  { id: 'read', text: '요구사항 읽기', done: false },
  { id: 'run', text: '예제 실행하기', done: true },
];
export function tasksReducer(tasks, action) {
  switch (action.type) {
    case 'added': {
      const text = action.text.trim();
      if (!text || tasks.some((task) => task.id === action.id)) return tasks;
      return [...tasks, { id: action.id, text, done: false }];
    }
    case 'toggled':
      return tasks.map((task) =>
        task.id === action.id ? { ...task, done: !task.done } : task,
      );
    case 'deleted':
      return tasks.filter((task) => task.id !== action.id);
    default:
      throw new Error(`알 수 없는 작업: ${action.type}`);
  }
}

src/tasks.test.js

import { tasksReducer } from './tasks.js';
const frozen = () =>
  Object.freeze([Object.freeze({ id: 'a', text: '읽기', done: false })]);
test('추가는 공백을 정리하며 원본을 변경하지 않는다', () => {
  const before = frozen();
  const after = tasksReducer(before, { type: 'added', id: 'b', text: '  쓰기  ' });
  expect(after).toEqual([...before, { id: 'b', text: '쓰기', done: false }]);
  expect(before).toHaveLength(1);
});
test('공백과 중복 ID는 거절한다', () => {
  const before = frozen();
  expect(tasksReducer(before, { type: 'added', id: 'b', text: ' ' })).toBe(before);
  expect(tasksReducer(before, { type: 'added', id: 'a', text: '다른 작업' })).toBe(
    before,
  );
});
test('토글은 해당 항목만 교체하고 두 번 토글하면 값이 복원된다', () => {
  const before = frozen();
  const once = tasksReducer(before, { type: 'toggled', id: 'a' });
  expect(once[0].done).toBe(true);
  expect(before[0].done).toBe(false);
  expect(tasksReducer(once, { type: 'toggled', id: 'a' })).toEqual(before);
});
test('삭제와 없는 ID는 데이터 규칙을 유지한다', () => {
  const before = frozen();
  expect(tasksReducer(before, { type: 'deleted', id: 'a' })).toEqual([]);
  expect(tasksReducer(before, { type: 'deleted', id: 'missing' })).toEqual(before);
});
test('같은 입력은 같은 결과를 만들고 알 수 없는 action은 오류다', () => {
  const before = frozen();
  const action = { type: 'added', id: 'b', text: '검토' };
  expect(tasksReducer(before, action)).toEqual(tasksReducer(before, action));
  expect(() => tasksReducer(before, { type: 'typo' })).toThrow('알 수 없는 작업');
});

src/test-setup.js

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

vite.config.js

import { defineConfig } from 'vite';
export default defineConfig({
  test: { environment: 'jsdom', globals: true, setupFiles: './src/test-setup.js' },
});

package.json 전체 코드 보기

index.html

index.html 전체 코드 보기

src/main.jsx

src/main.jsx 전체 코드 보기

1단계: DOM 없이 작업 변경 규칙 만들기

src/tasks.js

src/tasks.js 전체 코드 보기

tasksReducer의 첫 인자는 현재 작업 배열이고 두 번째 인자는 action입니다. added에는 id와 text가 필요하고 toggled와 deleted에는 id가 필요합니다. type은 이벤트의 의미를 구분하는 문자열입니다. 이 예제는 JavaScript이므로 action 구조가 맞다는 가정 아래 동작합니다. 외부 API가 보내는 임의 데이터를 그대로 dispatch하는 검증기로 사용하면 안 됩니다.

추가는 trim으로 문장을 정리한 뒤 빈 값과 중복 id를 거절합니다. 유효하지 않은 추가는 기존 tasks를 그대로 반환합니다. 이때 오류 문구를 reducer에서 만들거나 alert를 띄우지 않습니다. 사용자에게 빈 입력을 안내하는 책임은 폼에 있고 reducer의 검사도 배열 불변 조건을 지키기 위해 남겨 둡니다. UI가 아닌 다른 경로에서 나중에 action을 보내더라도 공백 작업을 넣지 않게 됩니다.

토글은 map으로 새 배열을 만들고 id가 일치하는 객체만 새 객체로 교체합니다. 기존 task.done을 직접 바꾸지 않습니다. 삭제는 filter로 해당 id를 제외합니다. 존재하지 않는 id의 토글이나 삭제는 내용상 변화가 없는 결과가 됩니다. 이 짧은 구현은 그 경우에도 새 배열을 반환할 수 있습니다. 그것만으로 버그는 아니며 성능 문제가 실제로 있다면 같은 참조 반환을 검토할 수 있습니다.

ID는 reducer 안에서 생성하지 않습니다. 같은 state와 action을 넣었을 때 결과가 일정해야 테스트와 재실행을 이해하기 쉽기 때문입니다. crypto.randomUUID()는 아래 폼의 제출 이벤트에서 실행하고 결과를 action에 넣습니다. API 요청, 현재 시각 읽기, 저장소 쓰기도 reducer 밖에서 처리할 일입니다. reducer는 다음 데이터를 계산하는 순수 함수로 유지합니다.

2단계: state와 dispatch를 Provider로 전달하기

src/TasksContext.jsx

src/TasksContext.jsx 전체 코드 보기

Provider는 useReducer를 호출하는 상태 소유자입니다. 자식들은 useTasks로 배열을 읽거나 useTasksDispatch로 변경 요청 함수를 얻습니다. Context의 기본값은 null이며 Provider 밖에서 훅을 사용하면 명확한 오류를 던집니다. 조용히 빈 배열을 반환하면 Provider 배치 실수를 데이터가 없는 상황과 구분하기 어려워집니다.

읽기용 Context와 dispatch용 Context를 나누면 작업을 추가하는 폼이 배열을 구독할 필요가 없습니다. 이것이 모든 자식의 렌더를 막는 최적화라는 뜻은 아닙니다. 부모 렌더 등 다른 이유로 컴포넌트는 다시 실행될 수 있습니다. 두 Context는 이 예제에서 데이터 읽기와 변경 요청의 의존성을 드러내기 위한 작은 분리이며 selector 라이브러리나 memo 설정까지 추가하지 않습니다.

Provider는 앱 전체에 무조건 올려야 하는 것이 아닙니다. 이번에는 작업 보드만 감싸며 이 범위 아래의 세 컴포넌트가 같은 배열을 봅니다. 보드 둘을 별도 Provider로 감싸면 각각의 상태를 가집니다. 여러 화면이 실제로 동일한 작업 집합을 공유해야 할 때 공통 상위 위치를 선택하면 됩니다. Context가 자동으로 서버나 다른 브라우저 탭과 상태를 동기화하지는 않습니다.

3단계: 폼·요약·목록을 각자의 책임대로 연결하기

src/App.jsx

src/App.jsx 전체 코드 보기

폼 제출에서 preventDefault를 호출해 페이지 이동을 막습니다. 공백 입력은 로컬 error로 안내하고 dispatch하지 않습니다. 정상 입력은 ID를 한 번 만들고 added action을 전달한 뒤 입력창을 비웁니다. dispatch 직후 같은 이벤트 안에서 tasks가 즉시 바뀐 것으로 읽지 않습니다. 다음 렌더에서 Provider가 새 배열을 전달하면 요약과 목록이 함께 변경됩니다.

TaskSummary는 작업 배열을 읽어 filter로 완료 수를 계산합니다. 사용자가 체크박스를 한 번 누르면 목록의 checked와 요약의 완료 수가 같은 새 배열을 기준으로 그려집니다. 전체 수와 완료 수를 각각 state로 만들면 삭제할 때 여러 setter를 빠짐없이 실행해야 하지만 여기서는 배열 하나가 기준이므로 별도 보정이 필요하지 않습니다.

TaskList의 체크박스는 checked를 데이터에서 받고 onChange에서 toggled를 보냅니다. label이 체크박스를 감싸므로 텍스트로도 항목을 구분할 수 있습니다. 삭제 버튼은 보이는 글자는 짧게 두고 접근 가능한 이름에 작업 내용을 포함했습니다. 동일한 이름의 작업을 여러 개 추가할 수 있으므로 실제 데이터 구분은 문자열 대신 id로 수행합니다. key에도 그 id를 사용합니다.

목록이 비었을 때는 등록된 작업이 없다는 문장을 표시합니다. 요약도 전체 0개·완료 0개가 되어야 합니다. 사용자는 정상적으로 모두 삭제한 결과와 화면 로딩 실패를 구분할 수 있습니다. 이 예제에는 비동기 로딩이 없으므로 로딩 스피너를 만들지 않았습니다. 추가·완료·삭제라는 현재 기능의 상태만 표시합니다.

src/styles.css

src/styles.css 전체 코드 보기

체크박스 옆 긴 텍스트는 줄바꿈될 수 있고 목록 항목은 작은 폭에서 버튼을 다음 줄로 배치할 수 있습니다. 입력과 버튼의 최소 높이, 키보드 외곽선을 유지합니다. 삭제 직후 사라진 버튼의 초점을 어디로 옮길지는 실제 제품에서 별도로 설계할 수 있습니다. 현재 실습은 공유 상태 전이를 다루며 완전한 목록 키보드 탐색 위젯을 구현하지는 않습니다.

4단계: 순수 함수와 사용자 동작을 따로 검증하기

vite.config.js

vite.config.js 전체 코드 보기

src/test-setup.js

src/test-setup.js 전체 코드 보기

src/tasks.test.js

src/tasks.test.js 전체 코드 보기

reducer 테스트는 React를 렌더링하지 않고 호출합니다. Object.freeze로 배열과 내부 객체를 동결해 직접 변경하는 실수를 드러냅니다. 배열만 동결하면 내부 객체 변경을 놓칠 수 있으므로 두 층을 모두 처리했습니다. 추가 후 원본 길이가 유지되는지와 토글 전 원본의 done이 그대로인지 확인합니다. 같은 action을 두 번 따로 계산했을 때 동일한 결과가 나오는지도 검사합니다.

순수성 테스트의 결과 비교는 현실적인 회귀 검사이지 모든 부수 효과가 없다는 수학적 증명은 아닙니다. 코드에서 시간 읽기나 외부 쓰기를 하지 않는지도 함께 검토해야 합니다. 중복 ID 거절과 알 수 없는 type의 오류는 규칙을 어긴 요청이 조용히 데이터를 손상시키지 않는지 확인합니다. 사용하지 않는 action을 자동으로 무시할지 오류를 낼지는 서비스 계약에 따라 정할 수 있으며 여기서는 오타 발견을 우선했습니다.

src/App.test.jsx

src/App.test.jsx 전체 코드 보기

통합 테스트는 이름으로 입력과 버튼을 찾아 실제 타이핑·클릭 경로를 실행합니다. 추가 후 전체 수가 늘고, 완료를 누르면 체크박스와 완료 수가 같이 바뀌며, 삭제 후 목록과 요약 모두에서 제거되는지 확인합니다. reducer 단위 테스트만 있으면 Context가 잘못 연결되거나 화면이 잘못된 값을 읽는 오류를 놓칠 수 있으므로 이 연결을 한 번에 검증합니다.

두 번째 화면 테스트는 빈 제출 오류와 모든 항목을 지운 상태를 확인합니다. 초기 상태가 두 항목이고 하나가 완료라는 전제도 테스트에 나타납니다. 제공 런타임에서 reducer 5개와 화면 2개, 총 7개 테스트가 통과했고 Vite 빌드도 통과했습니다. jsdom 환경의 자동 검사이며 브라우저에서 글자 잘림이나 포커스 외곽선까지 시각 확인한 결과는 아닙니다.

useReducer와 Context를 적용할 범위 판단

입력창 한 개와 버튼 한 개만 있는 화면이라면 useState와 props가 더 읽기 쉬울 수 있습니다. reducer가 유용한 기준은 상태가 크다는 막연한 인상보다 여러 이벤트가 동일한 구조를 서로 다른 규칙으로 바꾸는지입니다. Context가 필요한 기준도 props 한 번 전달이 귀찮다는 것보다 공통 값을 여러 단계 아래 소비자가 실제로 필요로 하는지입니다.

한 Context에 작업 배열, 테마, 로그인, 모든 폼 입력을 합치면 작은 입력 변경까지 관련 없는 소비자에게 전달될 수 있습니다. 그렇다고 항목마다 Context를 자동으로 늘릴 필요도 없습니다. 먼저 상태 소유 범위와 변경 빈도를 확인하고, 느린 화면은 측정한 다음 분리 기준을 정하세요. 이번 분리만으로 대규모 앱의 렌더링 성능이 보장되지는 않습니다.

서버 저장을 추가할 때 reducer 안에서 fetch를 실행하지 마세요. 요청 시작·성공·실패를 어디서 다룰지, 서버가 발급한 ID를 어떻게 반영할지, 실패한 추가를 되돌릴지부터 결정해야 합니다. 로컬 상태 변경과 서버의 확정 결과는 서로 다른 사건이므로 이 동기식 실습에 API 한 줄만 넣는 것으로 동일한 보장이 생기지 않습니다.

확장 연습은 이름 수정 action을 추가하는 것입니다. id와 새 text를 받아 공백을 거절하고 해당 항목만 새 객체로 교체하도록 reducer부터 작성하세요. 단위 테스트에서 원본 유지와 다른 항목 유지, 화면 테스트에서 수정 후 요약 수가 그대로인지를 확인하면 됩니다. 수정 중인 임시 입력은 편집 폼에 두고 확정할 때만 공유 배열에 반영하면 이번 실습의 상태 경계를 계속 유지할 수 있습니다.

공식 문서와 다음 실습

이 글이 도움이 되었나요?

조회 중

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 피드 구독하기

댓글 남기기