이 글에서 정리하는 내용
JSX를 HTML처럼 외우는 대신 JavaScript 안에서 UI를 표현하는 문법으로 이해합니다. 기본 문법, 중괄호 표현식, 조건부 렌더링, 리스트 렌더링과 style 같은 속성 바인딩, Fragment까지 한 흐름으로 정리합니다.
JSX는 무엇이고 왜 쓰는가

JSX는 React에서 자주 보이는 HTML 비슷한 문법이지만, 실제 HTML 파일 문법 자체는 아닙니다. JavaScript 파일 안에서 UI 구조를 더 읽기 쉽게 표현하기 위한 문법 확장이라고 이해하는 편이 정확합니다. 그래서 이 문법을 읽을 때는 태그 모양만 보는 것이 아니라, 이 코드가 결국 JavaScript 안에서 평가된다는 점을 같이 봐야 합니다.
처음에는 HTML을 그대로 옮겨 적는 느낌이 강하지만, 조금만 들어가 보면 차이가 분명합니다. 태그를 반드시 닫아야 하고, 여러 요소를 반환할 때 하나의 부모로 감싸야 하며, class 대신 className을 쓰는 식으로 JavaScript 문법과 React 규칙이 섞여 있습니다. 특히 <img> 같은 단일 태그도 이 문법에서는 <img />처럼 명시적으로 닫아야 하므로, HTML보다 조금 더 엄격한 문법으로 보는 편이 좋습니다. 즉 이 구조는 마크업처럼 보이지만, 화면을 설명하는 JavaScript 코드에 더 가깝습니다.
HTML처럼 보여도 그대로 HTML은 아닙니다
// JSX는 JavaScript 안에서 UI를 표현하는 문법입니다.
function Greeting() { const name = '해비'; return ( <section> <h1>안녕하세요, {name}님</h1> <p>이 영역은 JSX로 작성한 UI입니다.</p> </section> );
}
위 코드는 화면에 제목과 문단을 그리는 예시이지만, HTML 파일에 직접 작성한 것이 아니라 함수 안에서 반환한 화면 구조입니다. 따라서 단순 마크업이 아니라 컴포넌트가 반환하는 값으로 이해해야 합니다. 이 감각이 잡혀야 이후 조건 분기나 리스트 출력도 자연스럽게 이어집니다.
중괄호로 JavaScript 넣기
이 문법에서 가장 중요한 요소 중 하나가 중괄호입니다. 중괄호는 화면 코드 안으로 JavaScript 값을 가져오는 통로입니다. 문자열을 그대로 넣고 싶다면 따옴표를 쓰면 되고, 변수나 계산 결과처럼 JavaScript가 평가한 값을 넣고 싶다면 중괄호를 사용합니다.
여기서 핵심은 아무 코드나 넣는 것이 아니라 값으로 평가될 수 있는 표현식을 넣는다는 점입니다. 변수, 숫자 계산, 문자열 결합, 함수 호출 결과, 삼항 연산자 결과처럼 최종적으로 어떤 값이 되는 것들은 중괄호 안에 넣을 수 있습니다. 반대로 문장 자체를 그대로 넣는 방식으로 이해하면 금방 헷갈리게 됩니다.
문자열과 표현식은 넣는 방식이 다릅니다
// 따옴표는 문자열, 중괄호는 JavaScript 값입니다.
function Profile() { const nickname = '몽이'; const age = 4; const isAdult = age >= 20; return ( <div className="profile-card" title={nickname}> <p>이름: {nickname}</p> <p>나이: {age}</p> <p>성인 여부: {isAdult ? '성인' : '미성년자'}</p> </div> );
}
title="프로필"처럼 따옴표를 사용하면 단순 문자열이 들어갑니다. 반면 {nickname}처럼 중괄호를 사용하면 JavaScript 변수 값이 들어갑니다. {isAdult ? '성인' : '미성년자'}처럼 삼항 연산자 결과도 화면 코드 안에서 바로 사용할 수 있습니다.
| 형태 | 의미 |
|---|---|
| title=”hello” | 문자열 hello를 그대로 전달 |
| title={nickname} | nickname 변수의 값을 전달 |
조건부 렌더링과 리스트 렌더링
React에서는 화면 분기를 위한 별도 템플릿 문법을 배우기보다, 익숙한 JavaScript 조건문을 화면 구조와 함께 사용합니다. 그래서 조건부 렌더링은 React 전용 문법을 외우는 방식보다, 어떤 경우에 어떤 UI를 보여줄지 JavaScript로 분기한다고 이해하는 편이 더 정확합니다.
또한 여러 개의 비슷한 UI를 반복해서 출력할 때는 배열을 map()으로 순회하면서 요소를 반환합니다. 이 흐름이 익숙해지면 메뉴 목록, 카드 목록, 댓글 목록처럼 실무에서 자주 다루는 반복 UI를 훨씬 자연스럽게 작성할 수 있습니다.
조건에 따라 다른 UI를 보여주기
// 조건부 렌더링은 JavaScript 분기를 사용합니다.
function LoginStatus() { const isLoggedIn = true; const unreadCount = 3; return ( <section> <h2>알림</h2> {isLoggedIn ? ( <p>새 알림이 {unreadCount}개 있습니다.</p> ) : ( <p>로그인이 필요합니다.</p> )} {unreadCount > 0 && <button>알림 확인</button>} </section> );
}
삼항 연산자는 둘 중 하나를 분명하게 골라야 할 때 적합하고, &&는 조건이 참일 때만 짧게 어떤 요소를 추가하고 싶을 때 자주 사용합니다. 복잡한 분기는 컴포넌트 위쪽에서 if로 나눈 뒤 반환하는 편이 더 읽기 좋을 때도 많습니다.
배열을 map으로 돌려 목록 출력하기
// 리스트 렌더링에서는 key를 함께 지정합니다.
function MenuList() { const menus = [ { id: 1, name: '아메리카노' }, { id: 2, name: '라떼' }, { id: 3, name: '바닐라 콜드브루' }]; return ( <ul> {menus.map((menu) => ( <li key={menu.id}>{menu.name}</li> ))} </ul> );
}
배열을 map()으로 돌릴 때는 각 항목이 어떤 요소로 바뀌는지를 한눈에 볼 수 있어야 합니다. 또한 목록에서는 key를 함께 넣어야 React가 각 항목을 안정적으로 구분할 수 있습니다. 이때 key는 렌더링마다 바뀌는 값보다 데이터에 이미 들어 있는 안정적인 ID를 쓰는 편이 좋고, 순서가 바뀔 수 있는 목록에서 인덱스를 그대로 key로 쓰면 추적이 꼬일 수 있습니다. 이 패턴은 상품 리스트, 방명록, 탭 메뉴, 카드형 UI 등 거의 모든 반복 렌더링에 공통으로 쓰입니다.
className, style, 속성 바인딩과 Fragment

이 문법에서는 HTML 속성을 그대로 옮겨 적는 것처럼 보여도 실제로는 JavaScript 값과 연결되는 경우가 많습니다. 대표적으로 className은 문자열, style은 객체, disabled는 boolean 값을 받습니다. 변수나 조건식도 해당 속성이 요구하는 종류의 값이어야 합니다. 그래서 속성 바인딩은 마크업 작성이라기보다 UI 상태를 값으로 연결하는 작업에 가깝습니다.
Fragment는 여러 요소를 묶어 반환해야 하지만 불필요한 div를 추가하고 싶지 않을 때 사용합니다. 레이아웃을 위한 wrapper가 실제 DOM에 꼭 필요하지 않은 상황이라면 Fragment를 써서 구조를 더 깔끔하게 유지할 수 있습니다.
속성 바인딩과 style 객체 이해하기
// style은 객체로 넣고, className은 문자열 또는 표현식으로 넣습니다.
function ProductCard() { const isSoldOut = false; const cardClassName = isSoldOut ? 'card soldout' : 'card'; const priceColor = isSoldOut ? '#999' : '#111'; return ( <article className={cardClassName} style={{ padding: '16px', color: priceColor }} aria-hidden={false} > <h3>프리미엄 청첩장</h3> <button disabled={isSoldOut}> {isSoldOut ? '품절' : '구매하기'} </button> </article> );
}
className에는 문자열을 직접 넣을 수도 있고, 조건식 결과를 넣을 수도 있습니다. style={{ ... }}은 특수한 새로운 문법처럼 느껴질 수 있지만, 실제로는 중괄호 안에 JavaScript 객체를 넣는 형태입니다. 값이 JavaScript 상태와 연결될 때는 이런 방식이 특히 유용합니다.
불필요한 div 없이 묶고 싶다면 Fragment
// Fragment는 여러 JSX 요소를 그룹으로 묶을 때 사용합니다.
function PageHeader() { return ( <> <h1>주문 내역</h1> <p>최근 30일 기준으로 정리했습니다.</p> </> );
}
짧은 문법인 <>...</>는 Fragment 축약형입니다. 화면에는 제목과 문단이 나란히 렌더링되지만, 이를 감싸는 추가 DOM 요소는 생기지 않습니다. 다만 목록 안에서 여러 요소를 한 묶음으로 반환하면서 key까지 넣어야 한다면 축약형 대신 <Fragment key={...}> 형태를 사용해야 합니다. 레이아웃 구조를 최소화하고 싶을 때 매우 자주 쓰이는 패턴입니다.
정리
이 주제를 처음 공부할 때 가장 중요한 관점은 HTML처럼 생긴 코드라는 인상에서 한 단계 더 나아가는 것입니다. 이 문법은 JavaScript 안에서 UI를 설명하기 때문에, 변수 출력도 되고 조건 분기도 되고 배열 반복도 됩니다. 결국 이를 잘 읽는다는 것은 마크업과 로직이 만나는 지점을 자연스럽게 해석할 수 있다는 뜻입니다.
정리하면, 문자열은 따옴표로 넣고 JavaScript 값은 중괄호로 넣습니다. 조건부 렌더링은 삼항 연산자나 && 분기로 나눠 생각하면 됩니다. 반복 UI는 map()으로 만들고, 속성은 값에 따라 바인딩하며, 불필요한 감싸기 요소를 줄이고 싶다면 Fragment를 사용하면 됩니다. 이 흐름이 익숙해지면 이 문법은 더 이상 낯선 형식이 아니라 React UI를 읽는 기본 언어처럼 느껴지게 됩니다.
많이 받는 질문
Q. JSX는 결국 HTML과 같은 건가요?
같아 보이지만 같지 않습니다. HTML 비슷한 문법으로 UI를 표현하지만, 실제로는 JavaScript 안에서 동작하는 React 코드입니다.
Q. 중괄호 안에는 무엇이든 넣을 수 있나요?
값으로 평가되는 표현식을 넣는다고 이해하는 편이 좋습니다. 변수, 계산식, 함수 호출 결과, 삼항 연산자 결과처럼 최종적으로 값이 되는 것들이 주로 들어갑니다.
Q. 리스트 렌더링에서 key는 왜 필요한가요?
React가 각 항목을 구분해 어떤 항목이 추가, 삭제, 이동됐는지 파악하기 위해 필요합니다. 가능하면 데이터 기반의 안정적인 ID를 쓰는 편이 좋습니다.
Q. Fragment와 div는 어떻게 다르나요?div는 실제 DOM 요소가 추가되지만 Fragment는 여러 JSX 요소를 묶기만 하고 추가 DOM을 만들지 않습니다. 구조를 불필요하게 늘리고 싶지 않을 때 Fragment가 적합합니다.
조건식이 0이거나 객체인 경우
{unreadCount && <p>알림</p>}에서 unreadCount가 0이면 결과도 숫자 0이어서 화면에 출력됩니다. unreadCount > 0 && …로 boolean 조건을 만드세요. false·null·undefined는 표시되지 않지만 숫자 0은 표시됩니다.
중괄호에 표현식을 쓸 수 있다는 말은 그 결과가 모두 화면 자식으로 허용된다는 뜻은 아닙니다. 일반 객체를 {user}로 직접 출력하지 말고 {user.name} 같은 필드를 고르세요. style={{ color: "#222" }}의 객체는 자식이 아니라 style prop 값입니다. disabled="false"처럼 문자열을 쓰지 말고 disabled={false}를 사용합니다.
key는 같은 배열의 형제 사이에서 고유하고 항목이 이동해도 유지되는 값을 사용합니다. map이 직접 반환하는 요소나 Fragment에 지정하며 자식의 일반 props에는 전달되지 않습니다. 자식도 ID가 필요하면 id={item.id}를 별도로 넘기세요.
직접 실행할 실습
MenuList에서 빈 배열 분기를 만들고 알림 조건을 unreadCount > 0으로 고치세요. map의 Fragment key는 menu.id로 유지하세요. items=[]와 unreadCount=0에서 의도치 않은 숫자 0이 없는지 확인하세요.
아래는 완성본의 src/App.jsx입니다. ZIP에는 별도 HTML·CSS, 진입점, 필요한 자식 컴포넌트, 고정 패키지 버전과 테스트가 함께 들어 있습니다. starter는 미완성 동작을 수정하는 시작본이고 complete는 비교용 완성본입니다.
실습 파일
파일 경로를 확인하고 같은 프로젝트 안에 저장하세요. 이미지 등 소스 목록에 없는 파일과 실행 안내는 실습 ZIP에 포함되어 있습니다.
조건부 출력과 반복되는 JSX 구조를 완성합니다. 수정 전은 시작 상태, 수정 후는 비교할 완성 상태입니다.
수정 전
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-2283",
"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, { Fragment } from 'react';
export const menus = [
{ id: 'coffee', name: '아메리카노', price: 3500, soldOut: false },
{ id: 'latte', name: '라떼', price: 4200, soldOut: true },
];
export function MenuList({ items }) {
return (
<dl>
{items.map((menu) => (
<Fragment key={menu.id}>
<dt>{menu.name}</dt>
<dd>
{menu.price.toLocaleString('ko-KR')}원 ·{' '}
{menu.soldOut ? '품절' : '주문 가능'}
</dd>
</Fragment>
))}
</dl>
);
}
export default function App() {
const unreadCount = 0;
return (
<main>
<h1>오늘의 메뉴</h1>
{unreadCount && <p>알림 {unreadCount}개</p>}
<MenuList items={menus} />
<section aria-label="빈 목록 예시">
<h2>내일의 메뉴</h2>
<MenuList items={[]} />
</section>
</main>
);
}
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('handles populated and empty arrays without a stray zero', () => {
const { container } = render(<App />);
expect(screen.getByText(/3,500원 · 주문 가능/)).toBeTruthy();
expect(screen.getByText(/4,200원 · 품절/)).toBeTruthy();
expect(screen.getByText('메뉴가 없습니다.')).toBeTruthy();
expect(
[...container.querySelector('main').childNodes].some(
(node) => node.nodeType === 3 && node.textContent.trim() === '0',
),
).toBe(false);
});
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/App.jsx에서 아래 차이가 있는 위치를 수정하세요. -는 삭제할 줄, +는 추가할 줄이며 표시는 실제 코드에 넣지 않습니다.
조건부 출력과 반복되는 JSX 구조를 완성합니다.
--- 수정 전/src/App.jsx
+++ 수정 후/src/App.jsx
@@ -6,6 +6,7 @@
];
export function MenuList({ items }) {
+ if (items.length === 0) return <p>메뉴가 없습니다.</p>;
return (
<dl>
{items.map((menu) => (
@@ -26,7 +27,7 @@
return (
<main>
<h1>오늘의 메뉴</h1>
- {unreadCount && <p>알림 {unreadCount}개</p>}
+ {unreadCount > 0 && <p>알림 {unreadCount}개</p>}
<MenuList items={menus} />
<section aria-label="빈 목록 예시">
<h2>내일의 메뉴</h2>
수정 후 탭의 전체 파일과 비교한 뒤 본문의 확인 절차를 실행하세요.
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-2283",
"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, { Fragment } from 'react';
export const menus = [
{ id: 'coffee', name: '아메리카노', price: 3500, soldOut: false },
{ id: 'latte', name: '라떼', price: 4200, soldOut: true },
];
export function MenuList({ items }) {
if (items.length === 0) return <p>메뉴가 없습니다.</p>;
return (
<dl>
{items.map((menu) => (
<Fragment key={menu.id}>
<dt>{menu.name}</dt>
<dd>
{menu.price.toLocaleString('ko-KR')}원 ·{' '}
{menu.soldOut ? '품절' : '주문 가능'}
</dd>
</Fragment>
))}
</dl>
);
}
export default function App() {
const unreadCount = 0;
return (
<main>
<h1>오늘의 메뉴</h1>
{unreadCount > 0 && <p>알림 {unreadCount}개</p>}
<MenuList items={menus} />
<section aria-label="빈 목록 예시">
<h2>내일의 메뉴</h2>
<MenuList items={[]} />
</section>
</main>
);
}
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('handles populated and empty arrays without a stray zero', () => {
const { container } = render(<App />);
expect(screen.getByText(/3,500원 · 주문 가능/)).toBeTruthy();
expect(screen.getByText(/4,200원 · 품절/)).toBeTruthy();
expect(screen.getByText('메뉴가 없습니다.')).toBeTruthy();
expect(
[...container.querySelector('main').childNodes].some(
(node) => node.nodeType === 3 && node.textContent.trim() === '0',
),
).toBe(false);
});
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' },
});
기대 결과: 아메리카노는 주문 가능, 라떼는 품절로 표시됩니다. 내일의 메뉴는 “메뉴가 없습니다.”이며 숫자 0만 따로 나타나지 않습니다.
압축을 풀고 complete에서 npm install 후 npm run dev를 실행합니다. 빌드·검증은 별도 터미널에서 npm test, npm run build로 실행합니다. 자세한 시작본 과제와 파일 설명은 README에 있습니다.
실습의 공식 참고 문서
이 글이 도움이 되었나요?
React 학습 순서
필수 19개 · 전체 23개
읽음 기록 관리
전체 과정 목차 (23개)
- 필수 길잡이 · React 학습 순서: 컴포넌트·props·state부터 상태관리까지
- 필수 학습 · React Vite 사용법: Vite 8 프로젝트 생성·실행·빌드
- 필수 학습 · React 컴포넌트 개념 정리: UI 재사용 구조 잡기
- 필수 학습 · React JSX 문법 사용법: 조건부 렌더링과 리스트 처리 현재 글
- 필수 학습 · React props 사용법: 부모에서 자식으로 데이터 전달하는 구조
- 필수 학습 · React useState 사용법: state와 객체 배열 업데이트 기준
- 선택 참고 · React state 업데이트 안됨 문제 해결
- 필수 선수 · React 리스트 key 경고 해결 기준: index key를 피해야 하는 이유
- 필수 학습 · React 제어 컴포넌트 폼과 상태 끌어올리기 첫 실습 가이드
- 필수 학습 · React useRef 실습: 입력 포커스와 state의 역할 나누기
- 필수 선수 · React useEffect 두 번 실행되는 이유: StrictMode·API 중복 해결
- 선택 참고 · React Maximum update depth exceeded 오류 해결: 무한 렌더링 원인 찾기
- 선택 참고 · React Cannot update 오류 해결: 렌더링 중 setState 원인
- 필수 학습 · React useReducer와 Context 실습: 작업 목록 상태를 여러 컴포넌트에서 공유하기
- 필수 학습 · React 컴포넌트 props 타입 지정하기: 부모와 자식 사이의 값 구조 잡기
- 선택 참고 · React Hook Form 에러 메시지 표시 문제 해결: validation이 안 보일 때 체크리스트
- 필수 길잡이 · React 라이브러리 조합 가이드: Zustand·TanStack Query·shadcn/ui 선택 기준
- 필수 학습 · Next.js 커스텀 훅 설계: 프론트엔드 상태 관리 구조 잡기
- 필수 학습 · React Compiler 기준: useMemo useCallback 언제 줄일까
- 필수 학습 · React·TypeScript 검색 필터 만들기: 상태와 결과 목록 연결
- 필수 학습 · React Router v7 실습: BrowserRouter부터 Layout·Outlet·상세 경로까지
- 필수 학습 · shadcn/ui 시작 실습: Vite 설정·components.json·입력 폼·Sonner
- 필수 학습 · shadcn/ui 복합 컴포넌트 실습: Dialog·Popover·Carousel과 접근성
새 글 받아보기
RSS 리더에서 BlogFlow의 새 글을 확인할 수 있습니다.