React 리스트 key 경고 해결 기준: index key를 피해야 하는 이유

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

React 리스트에서 key 경고가 나는 이유와 고치는 기준

React에서 리스트를 렌더링할 때 보이는 key 경고는 콘솔 문구를 지우는 문제로 끝나지 않습니다. React가 여러 항목을 비교할 때 어떤 항목이 같은 데이터인지 판단할 수 있도록, 형제 항목 사이에서 고유하고 렌더링마다 바뀌지 않는 안정적인 식별자를 주는 작업입니다.

콘솔 경고가 가리키는 위치부터 보기

React key 경고 원인 진단: 배열 map 고유 key 순서 변경 흐름

React에서 배열을 map으로 렌더링하다 보면 콘솔에 Warning: Each child in a list should have a unique "key" prop. 경고가 나타날 수 있습니다. 화면은 정상적으로 보일 때도 있어서 처음에는 대수롭지 않게 넘기기 쉽습니다. 하지만 이 경고는 React가 리스트 항목을 구분할 값을 충분히 받지 못했다는 신호입니다.

상품 카드, 댓글 목록, 장바구니 항목, 관리자 테이블처럼 같은 컴포넌트를 반복해서 그리는 화면에서는 이 문제가 자주 나옵니다. 경고가 뜬 위치를 따라가 보면 보통 map 안에서 반환하는 가장 바깥 요소에 key가 없거나, 같은 배열 안에서 동일한 key를 반복해서 쓰고 있습니다.

처음 확인할 때는 “리스트 key 경고”와 “개발 중 로그가 두 번 찍히는 현상”을 분리해야 합니다. 두 현상이 같이 보이면 key 문제가 렌더링 횟수 문제처럼 느껴질 수 있습니다. 개발 환경에서 useEffect 로그가 반복되는 상황까지 겹쳤다면 React useEffect가 두 번 실행되는 이유를 따로 확인해두면 원인을 나누어 볼 수 있습니다.

React가 리스트 항목을 다시 찾는 방식

key는 화면에 출력되는 값이 아닙니다. React가 이전 렌더링 결과와 다음 렌더링 결과를 비교할 때, 리스트 안의 각 항목을 식별하기 위해 사용하는 특수한 값입니다. 같은 위치에 있는 항목이 정말 같은 데이터인지, 아니면 다른 데이터가 그 자리에 들어온 것인지 구분하는 데 쓰입니다.

예를 들어 댓글 목록에서 두 번째 댓글이 삭제되면, 기존 세 번째 댓글은 화면상 두 번째 위치로 올라옵니다. React가 위치만 보고 비교하면 “두 번째 자리에 있던 컴포넌트를 계속 재사용해도 된다”고 판단할 수 있습니다. 하지만 데이터 관점에서는 기존 두 번째 댓글이 사라지고 기존 세 번째 댓글이 이동한 상황입니다. 이 차이를 알려주는 값이 key입니다.

key는 전체 프로젝트에서 전역으로 유일할 필요는 없습니다. 같은 배열 안에서 형제 항목끼리만 유일하면 됩니다. 다만 렌더링할 때마다 새로 만들어지는 Math.random(), Date.now() 같은 값은 피해야 합니다. React 공식 문서도 key가 바뀌면 목적을 잃는다고 설명하므로, 데이터가 가진 안정적인 id를 우선으로 사용합니다.

key가 없는 기본 예시

{products.map((product) => (
    <ProductCard product={product} />
))}

이 코드는 화면에 카드가 출력될 수는 있습니다. 다만 React 입장에서는 반복된 ProductCard 중 어떤 카드가 어떤 상품을 나타내는지 안정적으로 구분하기 어렵습니다. 필터를 적용하거나 정렬을 바꾸면 어떤 항목이 추가, 삭제, 이동되었는지 추적하는 근거가 부족해집니다.

데이터의 id를 key로 쓰는 예시

{products.map((product) => (
    <ProductCard key={product.id} product={product} />
))}

서버 데이터나 로컬 데이터에 고유한 id가 있다면 그 값을 key로 쓰는 것이 가장 안정적입니다. product.id가 상품 하나를 계속 같은 상품으로 식별해준다면, 정렬 순서가 바뀌어도 React는 항목의 정체성을 유지할 수 있습니다.

index key가 위험해지는 순간

index를 key로 쓰면 당장 경고는 사라집니다. 그래서 처음에는 가장 빠른 해결처럼 보입니다. 문제는 index가 데이터의 정체성이 아니라 배열에서의 위치라는 점입니다. 위치는 정렬, 삭제, 삽입, 필터링이 들어가는 순간 쉽게 바뀝니다.

할 일 목록에서 첫 번째 항목을 삭제하는 상황을 떠올리면 차이가 분명해집니다. 기존 두 번째 항목의 index는 1에서 0으로 바뀝니다. React는 key를 기준으로 컴포넌트를 비교하므로, 이전에 key={0}이던 컴포넌트를 새 첫 번째 항목에 재사용할 수 있습니다. 단순 텍스트만 있는 목록에서는 티가 덜 나지만, 항목 안에 입력값이나 체크 상태가 있으면 다른 항목에 상태가 붙어 보일 수 있습니다.

상황 index key 위험도 사용할 값
정적인 메뉴 목록 낮음 변하지 않는 문자열 key 또는 id
삭제 가능한 할 일 목록 높음 todo.id 같은 고유값
정렬되는 상품 카드 높음 product.id
필터가 적용되는 검색 결과 높음 검색 결과 항목의 원본 id

정적인 약관 목록처럼 순서가 바뀌지 않고 항목 추가나 삭제도 없는 화면이라면 index key가 큰 문제로 드러나지 않을 수 있습니다. 다만 실무 화면에서는 처음에는 정적인 목록이었다가 나중에 정렬, 필터, 삭제 기능이 붙는 경우가 많습니다. 처음부터 데이터가 가진 고유값을 key로 잡아두면 이후 기능이 붙어도 수정 범위가 줄어듭니다.

코드에서 key 위치를 고치는 방식

key 경고를 고칠 때는 “어떤 값으로 key를 만들지”보다 “어디에 key를 붙였는지”를 먼저 확인해야 합니다. map 안에서 반환하는 가장 바깥 요소에 붙이는 것이 기본입니다. 내부 컴포넌트 깊은 곳에 key를 붙여도, 배열의 직접 결과물인 형제 요소를 React가 구분하지 못하면 경고가 계속 남을 수 있습니다.

잘못된 위치에 key를 둔 경우

{products.map((product) => (
    <li>
        <ProductCard key={product.id} product={product} />
    </li>
))}

이 코드에서 배열이 직접 반환하는 요소는 ProductCard가 아니라 li입니다. 그런데 key는 그 안쪽 컴포넌트에 붙어 있습니다. React가 비교해야 하는 형제 요소는 li들이므로, 이 경우에는 li에 key를 붙여야 합니다.

반복되는 바깥 요소에 key를 둔 경우

{products.map((product) => (
    <li key={product.id}>
        <ProductCard product={product} />
    </li>
))}

이렇게 작성하면 React는 각 li가 어떤 상품을 나타내는지 알 수 있습니다. ProductCard 안에서도 상품 id가 필요하다면 key에 의존하지 말고 productId={product.id}처럼 별도의 prop으로 넘겨야 합니다. React의 special props 기준에 따라 key는 내부 비교에 사용하는 힌트이며 컴포넌트의 일반 props로 전달되지 않습니다.

Fragment를 반복할 때의 처리

{sections.map((section) => (
    <React.Fragment key={section.id}>
        <h3>{section.title}</h3>
        <p>{section.description}</p>
    </React.Fragment>
))}

짧은 Fragment 문법인 <></>에는 key를 직접 붙일 수 없습니다. 여러 태그를 묶어서 반복해야 하고 별도의 DOM 요소를 추가하고 싶지 않다면 React.Fragment를 명시해서 key를 붙이면 됩니다.

수정 후 확인할 체크포인트

React key 경고 해결 체크리스트: index key 주의 id 사용 중복 key 제거 흐름

key를 수정한 뒤에는 콘솔 경고가 사라졌는지만 보지 말고, 리스트가 바뀌는 상황을 같이 확인해야 합니다. 새 항목 추가, 중간 항목 삭제, 정렬 변경, 필터 적용을 순서대로 눌러보면 key가 안정적인지 빠르게 드러납니다.

입력 폼이 포함된 리스트에서는 더 꼼꼼하게 봐야 합니다. 예를 들어 장바구니 옵션 입력값, 체크박스 선택 상태, 펼침/접힘 상태가 있는 항목은 잘못된 key를 썼을 때 다른 항목으로 상태가 옮겨 붙은 것처럼 보일 수 있습니다.

리스트 수정 중 상태 업데이트가 반복되면서 별도의 렌더링 오류가 같이 생긴다면 key만 볼 문제가 아닐 수 있습니다. 무한 업데이트 경고가 함께 나온다면 React Maximum update depth 오류 해결처럼 상태 변경 위치를 따로 점검해야 합니다.

확인 순서

1. map 안에서 반환하는 가장 바깥 요소에 key가 있는지 확인
2. key가 배열 index가 아니라 데이터의 고유 id인지 확인
3. Math.random(), Date.now()처럼 렌더링마다 바뀌는 key를 쓰지 않았는지 확인
4. 같은 배열 안에서 key가 중복되지 않는지 확인
5. 삭제, 삽입, 정렬, 필터 상황에서 상태가 엉키지 않는지 확인
6. key가 컴포넌트 내부 props로 필요하면 별도 prop으로 전달

이 순서로 보면 경고를 없애는 작업과 실제 UI 안정성을 확인하는 작업을 분리할 수 있습니다. 콘솔 경고만 없애려면 index를 붙이는 것으로 끝날 수 있지만, 사용자가 데이터를 추가하고 삭제하는 화면에서는 그 정도로는 부족합니다.

마무리 점검

React의 key 경고는 리스트 항목을 식별할 값이 부족할 때 나오는 신호입니다. 반복되는 가장 바깥 요소에 붙이고, 같은 배열 안에서 형제 요소끼리 유일하게 만들고, 가능하면 데이터가 가진 안정적인 id를 사용하면 대부분의 경고를 정리할 수 있습니다.

index key는 경고를 빠르게 없앨 수는 있지만, 정렬이나 삭제가 들어가는 화면에서는 UI 상태가 꼬이는 원인이 될 수 있습니다. 카드, 댓글, 장바구니, 관리자 목록처럼 데이터가 움직이는 화면이라면 위치가 아니라 항목 자체를 나타내는 값을 key로 잡아두는 쪽이 이후 수정에 더 안정적입니다. 자세한 기준은 React 공식 문서의 Rendering Lists, Preserving and Resetting State, Fragment, Special Props Warning을 함께 확인하면 됩니다.

함께 확인하면 좋은 기준

이 글과 관련해 실제 작업에서 같이 확인하면 좋은 기준입니다.

  • React Rendering Lists: 리스트 key의 기본 규칙과 고유한 id 사용 기준을 확인합니다.
  • React Special Props Warning: key가 일반 props로 전달되지 않는 이유를 확인합니다.
  • React Fragment: Fragment를 반복 렌더링할 때 명시적 Fragment에 key를 붙이는 기준을 확인합니다.

직접 실행하는 최소 재현과 수정 실습

실습 ZIP 다운로드 · 3019번 글의 완성 프로젝트입니다. Node.js 22.12 이상에서 압축을 풀고 아래 명령을 실행합니다. HTML, CSS, JSX가 별도 파일이며 외부 API는 사용하지 않습니다.

npm install
npm run dev

# 결과 검증
npm test
npm run build
npm run preview

1. 원인을 확인하는 최소 오류 코드

{items.map((item, index) => <Row key={index} item={item} />)}

위 오류 코드를 실습의 map에 적용하면 위치를 나타내는 index가 key가 됩니다. Beta 메모를 고친 뒤 순서를 뒤집으면 메모가 Alpha에 붙는 현상을 볼 수 있습니다. 수정본은 Row 내부에 로컬 state를 일부러 두고 item.id를 key로 사용합니다. 콘솔 경고 유무보다 입력값이 데이터와 함께 이동하는지가 검증 기준입니다. 새 데이터의 id는 생성 시점에 한 번 부여해야 합니다. 삭제된 행을 나중에 다시 추가하면 새 컴포넌트가 마운트되므로 로컬 입력 state까지 복원되지는 않습니다.

2. App.jsx 수정본

실습 파일

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

이 실습은 오류를 재현하는 시작본과 설명, 검증된 완성본을 차례로 비교합니다. Beta 메모를 수정하고 순서를 뒤집으세요. 시작본은 메모가 Alpha 쪽으로 이동하지만 완성본은 Beta에 남습니다.

수정 전

App.jsx

import React, { useState } from 'react';

function Row({ item, onDelete }) {
  const [note, setNote] = useState(item.name);
  return (
    <li>
      <label>
        메모 {item.name}
        <input value={note} onChange={(event) => setNote(event.target.value)} />
      </label>
      <button type="button" onClick={onDelete}>
        {item.name} 삭제
      </button>
    </li>
  );
}

export default function App() {
  const [items, setItems] = useState([
    { id: 'a', name: 'Alpha' },
    { id: 'b', name: 'Beta' },
  ]);
  return (
    <main>
      <h1>목록의 key와 입력값</h1>
      <p>Beta의 메모를 바꾼 뒤 순서를 뒤집거나 Alpha를 삭제하세요.</p>
      <button
        type="button"
        onClick={() => setItems((previous) => [...previous].reverse())}
      >
        순서 뒤집기
      </button>
      <ul>
        {items.map((item, index) => (
          <Row
            key={index}
            item={item}
            onDelete={() =>
              setItems((previous) => previous.filter((row) => row.id !== item.id))
            }
          />
        ))}
      </ul>
      {items.length === 0 && <p role="status">항목이 없습니다.</p>}
    </main>
  );
}

App.test.jsx

import React, { StrictMode } from 'react';
import { it, expect, afterEach } from 'vitest';
import { render, screen, cleanup, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import App from './App.jsx';
afterEach(cleanup);
it('keeps edited text with its item after reorder and deletion', async () => {
  const user = userEvent.setup();
  render(<App />);
  await user.clear(screen.getByLabelText('메모 Beta'));
  await user.type(screen.getByLabelText('메모 Beta'), '내 메모');
  await user.click(screen.getByRole('button', { name: '순서 뒤집기' }));
  expect(screen.getByLabelText('메모 Beta').value).toBe('내 메모');
  await user.click(screen.getByRole('button', { name: 'Alpha 삭제' }));
  expect(screen.queryByLabelText('메모 Alpha')).toBeNull();
  expect(screen.getByLabelText('메모 Beta').value).toBe('내 메모');
});

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 실습 3019</title>
  </head>
  <body>
    <div id="root"></div>
    <script type="module" src="/main.jsx"></script>
  </body>
</html>

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>,
);

package.json

{
  "name": "react-practice-3019",
  "private": true,
  "version": "1.0.0",
  "type": "module",
  "engines": {
    "node": ">=22.12.0"
  },
  "scripts": {
    "dev": "vite --host 0.0.0.0",
    "build": "vite build",
    "preview": "vite preview --host 0.0.0.0",
    "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"
  }
}

styles.css

* {
  box-sizing: border-box;
}
body {
  margin: 0;
  color: #222;
  background: #fff;
  font-family: system-ui, sans-serif;
  line-height: 1.7;
}
main {
  max-width: 760px;
  margin: 40px auto;
  padding: 24px;
}
h1 {
  font-size: 1.7rem;
}
label {
  display: block;
  margin: 12px 0 6px;
}
input,
select,
button {
  font: inherit;
  color: #222;
  background: #fff;
  border: 1px solid #777;
  padding: 8px 12px;
}
input:not([type='checkbox']),
select {
  max-width: 100%;
}
button {
  margin: 8px 8px 8px 0;
  cursor: pointer;
}
button:disabled {
  color: #777;
  cursor: default;
}
[aria-pressed='true'] {
  background: #222;
  color: #fff;
}
:focus-visible {
  outline: 2px solid #222;
  outline-offset: 3px;
}
li {
  margin: 12px 0;
}
[role='alert'] {
  font-weight: 700;
}

vite.config.js

import { defineConfig } from 'vite';
export default defineConfig({ base: './', test: { environment: 'jsdom' } });

수정 방법

App.jsx

수정 위치: 목록을 그리는 items.map의 Row key={index}를 확인하세요.

이유: 배열 순서가 바뀌면 인덱스 key가 다른 상품에 같은 Row 인스턴스를 재사용해 내부 note state가 잘못 붙습니다. item.id를 key로 사용하면 항목의 정체성이 유지됩니다.

재현과 기대 결과: Beta 메모를 수정하고 순서를 뒤집으세요. 시작본은 메모가 Alpha 쪽으로 이동하지만 완성본은 Beta에 남습니다.

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

본문의 확인 절차로 변경 결과를 확인합니다.

--- 수정 전/App.jsx
+++ 수정 후/App.jsx
@@ -31,9 +31,9 @@
         순서 뒤집기
       </button>
       <ul>
-        {items.map((item, index) => (
+        {items.map((item) => (
           <Row
-            key={index}
+            key={item.id}
             item={item}
             onDelete={() =>
               setItems((previous) => previous.filter((row) => row.id !== item.id))

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

App.test.jsx

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

index.html

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

main.jsx

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

package.json

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

styles.css

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

vite.config.js

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

수정 후

App.jsx

import React, { useState } from 'react';

function Row({ item, onDelete }) {
  const [note, setNote] = useState(item.name);
  return (
    <li>
      <label>
        메모 {item.name}
        <input value={note} onChange={(event) => setNote(event.target.value)} />
      </label>
      <button type="button" onClick={onDelete}>
        {item.name} 삭제
      </button>
    </li>
  );
}

export default function App() {
  const [items, setItems] = useState([
    { id: 'a', name: 'Alpha' },
    { id: 'b', name: 'Beta' },
  ]);
  return (
    <main>
      <h1>목록의 key와 입력값</h1>
      <p>Beta의 메모를 바꾼 뒤 순서를 뒤집거나 Alpha를 삭제하세요.</p>
      <button
        type="button"
        onClick={() => setItems((previous) => [...previous].reverse())}
      >
        순서 뒤집기
      </button>
      <ul>
        {items.map((item) => (
          <Row
            key={item.id}
            item={item}
            onDelete={() =>
              setItems((previous) => previous.filter((row) => row.id !== item.id))
            }
          />
        ))}
      </ul>
      {items.length === 0 && <p role="status">항목이 없습니다.</p>}
    </main>
  );
}

App.test.jsx

import React, { StrictMode } from 'react';
import { it, expect, afterEach } from 'vitest';
import { render, screen, cleanup, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import App from './App.jsx';
afterEach(cleanup);
it('keeps edited text with its item after reorder and deletion', async () => {
  const user = userEvent.setup();
  render(<App />);
  await user.clear(screen.getByLabelText('메모 Beta'));
  await user.type(screen.getByLabelText('메모 Beta'), '내 메모');
  await user.click(screen.getByRole('button', { name: '순서 뒤집기' }));
  expect(screen.getByLabelText('메모 Beta').value).toBe('내 메모');
  await user.click(screen.getByRole('button', { name: 'Alpha 삭제' }));
  expect(screen.queryByLabelText('메모 Alpha')).toBeNull();
  expect(screen.getByLabelText('메모 Beta').value).toBe('내 메모');
});

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 실습 3019</title>
  </head>
  <body>
    <div id="root"></div>
    <script type="module" src="/main.jsx"></script>
  </body>
</html>

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>,
);

package.json

{
  "name": "react-practice-3019",
  "private": true,
  "version": "1.0.0",
  "type": "module",
  "engines": {
    "node": ">=22.12.0"
  },
  "scripts": {
    "dev": "vite --host 0.0.0.0",
    "build": "vite build",
    "preview": "vite preview --host 0.0.0.0",
    "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"
  }
}

styles.css

* {
  box-sizing: border-box;
}
body {
  margin: 0;
  color: #222;
  background: #fff;
  font-family: system-ui, sans-serif;
  line-height: 1.7;
}
main {
  max-width: 760px;
  margin: 40px auto;
  padding: 24px;
}
h1 {
  font-size: 1.7rem;
}
label {
  display: block;
  margin: 12px 0 6px;
}
input,
select,
button {
  font: inherit;
  color: #222;
  background: #fff;
  border: 1px solid #777;
  padding: 8px 12px;
}
input:not([type='checkbox']),
select {
  max-width: 100%;
}
button {
  margin: 8px 8px 8px 0;
  cursor: pointer;
}
button:disabled {
  color: #777;
  cursor: default;
}
[aria-pressed='true'] {
  background: #222;
  color: #fff;
}
:focus-visible {
  outline: 2px solid #222;
  outline-offset: 3px;
}
li {
  margin: 12px 0;
}
[role='alert'] {
  font-weight: 700;
}

vite.config.js

import { defineConfig } from 'vite';
export default defineConfig({ base: './', test: { environment: 'jsdom' } });

App.jsx 전체 코드 보기

3. 따라 해볼 순서와 완료 기준

Beta 메모를 수정 → 순서 뒤집기 → Alpha 삭제. Beta의 입력값이 그대로 남아야 합니다. 두 항목을 모두 지우면 빈 목록 안내가 나타납니다.

ZIP의 main.jsx에서 React 루트와 StrictMode를 구성하고 styles.css에서 흑백 UI를 적용합니다. App.test.jsx는 실제 입력·클릭 뒤 화면 결과를 확인합니다. 오류 코드는 broken-example.md에 설명용으로 보관되어 자동 실행되지 않습니다. 테스트는 jsdom에서 검증하며 브라우저의 시각적 배치까지 검증한 것은 아닙니다.

공식 참고 자료

이 글이 도움이 되었나요?

조회 중

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

댓글 남기기