React에서 객체 상태(state
)를 업데이트하는 방법을 배우고, 효율적으로 다룰 수 있는 다양한 기법을 살펴보겠습니다.
React의 state
는 객체를 포함하여 모든 JavaScript 값을 가질 수 있습니다.
하지만 state
객체를 직접 수정하면 안 됩니다!
새로운 객체를 생성하여 React의 상태 관리 방식과 일치하도록 해야 합니다.
React는 객체의 변경을 감지하지 않습니다. 따라서 객체의 참조를 새롭게 바꿔줘야 상태 변경을 인식하고 리렌더링이 발생합니다.
// 잘못된 방법
position.x = e.clientX;
position.y = e.clientY;
// 올바른 방법
setPosition({
x: e.clientX,
y: e.clientY,
});
다음 코드를 작성해 마우스 포인터를 따라 움직이는 빨간 점을 구현해 보겠습니다.
import { useState } from "react";
export default function AppMovingDot() {
const [position, setPosition] = useState({ x: 0, y: 0 });
return (
<div
onPointerMove={(e) => {
setPosition({
x: e.clientX,
y: e.clientY,
});
}}
style={{
position: "relative",
width: "100vw",
height: "100vh",
}}
>
<div
style={{
position: "absolute",
backgroundColor: "red",
borderRadius: "50%",
transform: `translate(${position.x}px, ${position.y}px)`,
left: -10,
top: -10,
width: 20,
height: 20,
}}
/>
</div>
);
}
React에서는 전개 구문(Spread Syntax)을 사용해 객체의 나머지 속성을 유지한 채로 특정 속성만 업데이트할 수 있습니다.
CourseForm.jsx
에서 강의 제목과 설명을 업데이트하는 폼을 구현합니다.
import { useState } from "react";
import Card from "../card/Card";
export default function CourseForm() {
const [form, setForm] = useState({
title: "리액트 강의",
description: "리액트 기초부터 실전까지!",
});
const handleTitleChange = (e) => {
setForm({
...form,
title: e.target.value,
});
};
const handleDescriptionChange = (e) => {
setForm({
...form,
description: e.target.value,
});
};
function handleCourseForm(e) {
e.preventDefault();
}
return (
<Card title="강의 등록">
<form
style={{ display: "flex", flexDirection: "column", gap: "1rem" }}
onSubmit={handleCourseForm}
>
<input
type="text"
placeholder="강의 제목"
value={form.title}
onChange={handleTitleChange}
/>
<input
type="text"
placeholder="강의 한줄 설명"
value={form.description}
onChange={handleDescriptionChange}
/>
<input type="submit" value="등록" />
{(form.title || form.description) && (
<div
style={{
marginTop: "16px",
padding: "16px",
backgroundColor: "#eee",
borderRadius: "6px",
}}
>
{form.title && <p>제목 - {form.title}</p>}
{form.description && <p>설명 - {form.description}</p>}
</div>
)}
</form>
</Card>
);
}
필드가 많아질수록 이벤트 핸들러를 개별적으로 정의하는 방식은 비효율적입니다.
const handleTitleChange = (e) => {
setForm({
...form,
title: e.target.value,
});
};
const handleDescriptionChange = (e) => {
setForm({
...form,
description: e.target.value,
});
};
[e.target.name]
을 활용해 동적으로 속성을 지정합니다.
const handleChange = (e) => {
setForm({
...form,
[e.target.name]: e.target.value,
});
};
name
속성 추가<input
type="text"
name="title"
placeholder="강의 제목"
value={form.title}
onChange={handleChange}
/>
<input
type="text"
name="description"
placeholder="강의 한줄 설명"
value={form.description}
onChange={handleChange}
/>
객체를 업데이트할 때는 새로운 객체를 생성해야 합니다.
하지만 중첩된 객체의 경우, 전개 구문(...)은 얕은 복사만 수행한다는 점에 유의해야 합니다.
즉, 한 레벨 깊이의 내용만 복사합니다.
중첩된 프로퍼티를 업데이트하려면 전개 구문을 한 번 이상 사용해야 합니다.
export default function CourseForm() {
const [form, setForm] = useState({
title: "리액트 강의",
description: "리액트 기초부터 실전까지!",
info: {
level: 1,
skill: "React",
},
});
function handleCourseForm(e) {
e.preventDefault();
}
const handleChange = (e) => {
setForm({
...form,
[e.target.name]: e.target.value,
});
};
const handleSkillChange = (e) => {
setForm({
...form,
info: {
...form.info,
skill: e.target.value,
},
});
};
const handleLevelChange = (e) => {
setForm({
...form,
info: {
...form.info,
level: e.target.value,
},
});
};
}
use-immer는 React 애플리케이션에서 상태 관리를 간편하게 만들어주는 라이브러리입니다.
Immer는 상태를 불변으로 유지하면서도 가변적인 스타일로 코딩할 수 있게 해줍니다.
import { useImmer } from "use-immer";
export default function CourseForm() {
const [form, updateForm] = useImmer({
title: "리액트 강의",
description: "리액트 기초부터 실전까지!",
info: {
level: 1,
skill: "React",
},
});
function handleCourseForm(e) {
e.preventDefault();
}
const handleChange = (e) => {
updateForm((draft) => {
draft[e.target.name] = e.target.value;
});
};
const handleSkillChange = (e) => {
updateForm((draft) => {
draft.info.skill = e.target.value;
});
};
const handleLevelChange = (e) => {
updateForm((draft) => {
draft.info.level = e.target.value;
});
};
}
Immer
가 제공하는 draft는 Proxy 객체입니다.
이 객체는 사용자의 작업을 "기록"하며, 이를 통해 객체를 원하는 만큼 자유롭게 변경할 수 있습니다.
React에서 배열을 State로 사용할 경우, 객체와 마찬가지로 읽기 전용으로 처리해야 합니다.
배열을 업데이트하려면 새로운 배열을 생성하거나 기존 배열의 복사본을 생성한 뒤, 이를 State로 설정해야 합니다.
배열을 변경할 때는 기존 배열을 변경하지 않고, 아래와 같은 방식을 사용해야 합니다:
todos.push({ id: nextId, text: "새로운 할 일" });
setTodos([...todos, { id: nextId, text: "새로운 할 일" }]);
AppTodo.jsx
import { useState } from "react";
import "./App.css";
import TodoList from "./components/todo/TodoList";
function AppTodo(props) {
const [todoText, setTodoText] = useState("");
const [todos, setTodos] = useState([
{ id: 0, text: "HTML&CSS 공부하기" },
{ id: 1, text: "자바스크립트 공부하기" },
]);
const handleTodoTextChange = (e) => {
setTodoText(e.target.value);
};
const handleAddTodo = () => {
const nextId = todos.length;
setTodos([...todos, { id: nextId, text: todoText }]);
setTodoText("");
};
return (
<div>
<h2>할일 목록</h2>
<input type="text" value={todoText} onChange={handleTodoTextChange} />
<button onClick={handleAddTodo}>추가</button>
<div>Preview: {todoText}</div>
<TodoList todos={todos} />
</div>
);
}
export default AppTodo;
Input 값 업데이트:
사용자가 텍스트를 입력하면 setTodoText
를 사용하여 todoText
State를 업데이트합니다.
할 일 추가:
handleAddTodo
함수가 실행되면 새로운 할 일이 추가됩니다:
setTodos([...todos, { id: nextId, text: todoText }]);
여기서 새로운 배열을 생성하여 State로 설정합니다.
Input 값 초기화:
할 일을 추가한 뒤, setTodoText("")
를 호출하여 입력 필드를 초기화합니다.
TodoList.jsx
function TodoList({ todos = [], onDeleteTodo }) {
return (
<ul>
{todos.map((item) => (
<li key={item.id}>
<span>{item.text}</span>
<button onClick={() => onDeleteTodo(item.id)}>X</button>
</li>
))}
</ul>
);
}
export default TodoList;
AppTodo.jsx
const handleDeleteTodo = (deleteId) => {
const newTodos = todos.filter((item) => item.id !== deleteId);
setTodos(newTodos);
};
React에서 배열을 다룰 때, 다음과 같은 함수를 사용해야 합니다:
작업 | 비추천 (배열을 변경) | 추천 (새 배열 반환) |
---|---|---|
추가 | push , unshift | concat , [...arr] (전개 연산자) |
제거 | pop , shift , splice | filter , slice |
수정 | splice , 직접 할당 | map |
정렬 | reverse , sort | 복사 후 정렬 |
const handleKeyDown = (e) => {
if (e.key === "Enter") {
handleAddTodo();
}
};
<input
type="text"
value={todoText}
onChange={handleTodoTextChange}
onKeyDown={handleKeyDown}
/>
TodoList.jsx
function TodoList({ todos = [], onToggleTodo }) {
return (
<ul>
{todos.map((item) => (
<li key={item.id}>
<input
type="checkbox"
checked={item.done}
onChange={(e) => onToggleTodo(item.id, e.target.checked)}
/>
<span>{item.done ? <del>{item.text}</del> : item.text}</span>
</li>
))}
</ul>
);
}
export default TodoList;
AppTodo.jsx
const handleToggleTodo = (id, done) => {
const nextTodos = todos.map((item) => {
if (item.id === id) {
return { ...item, done };
}
return item;
});
setTodos(nextTodos);
};
const handleAddTodoByIndex = () => {
const newTodos = [
...todos.slice(0, insertAt), // 삽입 위치 이전의 항목
{ id: todos.length, text: todoText, done: false }, // 새 항목
...todos.slice(insertAt), // 삽입 위치 이후의 항목
];
setTodos(newTodos);
setTodoText("");
};
<select value={insertAt} onChange={(e) => setInsertAt(e.target.value)}>
{todos.map((_, index) => (
<option key={index} value={index}>
{index} 번째
</option>
))}
</select>
<button onClick={handleAddTodoByIndex}>삽입</button>
배열을 직접 복사하고 항목을 수정하는 방식은 React에서 비효율적입니다.
예를 들어:
const nextTodos = [...copyTodos];
const targetItem = nextTodos.find((item) => item.id === id);
targetItem.done = done;
setCopyTodos(nextTodos);
이 방식은 React의 불변성 원칙을 위반할 가능성이 높습니다.
배열 State는 항상 새로운 배열로 업데이트해야 합니다.
filter
, map
, slice
와 같은 함수로 새 배열을 생성하세요.
입력 필드와 State를 동기화하세요.
onChange
와 onKeyDown
을 활용해 입력 값을 실시간으로 업데이트합니다.
배열 State에서 항목을 추가/제거/수정/삽입할 때는 불변성을 유지해야 합니다.
React의 선호 방식에 따라 배열을 다루면 코드의 가독성과 유지보수성이 향상됩니다.
변경 가능한 배열 메서드(push, pop 등)는 피하고, 새 배열을 반환하는 메서드(filter, map 등)를 사용하세요.
JavaScript는 배열을 다룰 때 다양한 배열 메서드를 제공합니다.
그중 불변성을 유지하는 메서드는 React와 같은 선언형 라이브러리에서 특히 유용합니다.
reverse
와 같은 일부 배열 메서드는 원본 배열을 직접 변경합니다.
이는 React의 불변성 원칙을 위반할 수 있으므로 지양해야 합니다.
<button onClick={handleReverse}>Reverse</button>
const handleReverse = () => {
const nextTodos = [...todos]; // 배열 복사
nextTodos.reverse(); // 복사본을 뒤집음
setTodos(nextTodos); // 새로운 배열을 state로 설정
};
위 코드는 원본 배열을 직접 변경하지 않기 위해 전개 연산자를 사용해 새로운 배열을 생성합니다.
하지만 최신 Array API를 사용하면 더 간단하고 효율적으로 불변성을 유지할 수 있습니다.
JavaScript의 최신 API는 불변성을 유지하면서 배열을 쉽게 다룰 수 있는 다양한 메서드를 제공합니다.
toReversed()
toReversed
는 배열의 순서를 뒤집은 새로운 배열을 반환합니다.
원본 배열은 변경되지 않습니다.
const numbers = [1, 2, 3];
const reversedNumbers = numbers.toReversed();
console.log(reversedNumbers); // [3, 2, 1]
console.log(numbers); // [1, 2, 3] (원본 배열은 변경되지 않음)
const handleReverse = () => {
setTodos(todos.toReversed());
};
toSorted()
toSorted
는 배열을 정렬한 새로운 배열을 반환합니다.
원본 배열은 변경되지 않습니다.
const numbers = [3, 1, 4, 1, 5, 9];
const sortedNumbers = numbers.toSorted();
console.log(sortedNumbers); // [1, 1, 3, 4, 5, 9]
console.log(numbers); // [3, 1, 4, 1, 5, 9] (원본 배열은 변경되지 않음)
const handleSort = () => {
setTodos(todos.toSorted((a, b) => a.id - b.id));
};
toSpliced()
toSpliced
는 지정된 인덱스에서 요소를 제거하거나 추가한 새로운 배열을 반환합니다.
원본 배열은 변경되지 않습니다.
const numbers = [1, 2, 3];
const splicedNumbers = numbers.toSpliced(1, 1, 4); // 인덱스 1에서 1개 요소 제거, 4 추가
console.log(splicedNumbers); // [1, 4, 3]
console.log(numbers); // [1, 2, 3] (원본 배열은 변경되지 않음)
const handleSplice = () => {
setTodos(todos.toSpliced(1, 1, { id: todos.length, text: "새 항목", done: false }));
};
with()
with
는 지정된 인덱스의 요소를 새로운 값으로 교체한 새로운 배열을 반환합니다.
원본 배열은 변경되지 않습니다.
const numbers = [1, 2, 3];
const withNewNumbers = numbers.with(1, 4); // 인덱스 1 값을 4로 변경
console.log(withNewNumbers); // [1, 4, 3]
console.log(numbers); // [1, 2, 3] (원본 배열은 변경되지 않음)
const handleUpdate = () => {
setTodos(todos.with(1, { ...todos[1], text: "업데이트된 텍스트" }));
};
메서드 | 기능 | 원본 변경 여부 |
---|---|---|
toReversed | 배열의 순서를 반대로 한 새 배열 반환 | ❌ |
toSorted | 배열을 정렬한 새 배열 반환 | ❌ |
toSpliced | 요소를 제거하거나 추가한 새 배열 반환 | ❌ |
with | 지정된 인덱스 요소를 교체한 새 배열 반환 | ❌ |
React와 최신 JavaScript API를 활용하면 코드의 유지보수성과 효율성을 크게 향상시킬 수 있습니다!
React에서 useState
를 사용해 강의 목록에 좋아요(즐겨찾기) 기능을 구현하는 방법과,
이를 useImmer
로 간단히 개선하는 방법을 다룹니다.
useState
와 useImmer
를 각각 활용하여 코드를 작성합니다.useState
를 활용한 기본 구현AppCourse.jsx
강의 목록 데이터를 useState
로 관리하며, 좋아요 상태를 업데이트합니다.
import './AppCourse.css';
import CourseForm from './components/course/CourseForm';
import CourseListCard from './components/course/CourseListCard';
import { useState } from 'react';
function App() {
const [items, setItems] = useState([
{
id: 0,
title: '입문자를 위한, HTML&CSS 웹 개발 입문',
description: '웹 개발에 필요한 기본 지식을 배웁니다.',
thumbnail: '/img/htmlcss.png',
isFavorite: false,
link: 'https://inf.run/JxyyT',
},
{
id: 1,
title: '입문자를 위한, ES6+ 최신 자바스크립트 입문',
description: '쉽고! 알찬! 내용을 준비했습니다.',
thumbnail: '/img/js.png',
isFavorite: true,
link: 'https://inf.run/Kpnd',
},
{
id: 2,
title: '포트폴리오 사이트 만들고 배포까지!',
description: '포트폴리오 사이트를 만들고 배포해 보세요.',
thumbnail: '/img/portfolio.png',
isFavorite: true,
link: 'https://inf.run/YkAN',
},
]);
const handleFavoriteChange = (id, isFavorite) => {
const newItems = items.map((item) =>
item.id === id ? { ...item, isFavorite } : item
);
setItems(newItems);
};
const favoriteItems = items.filter((item) => item.isFavorite);
return (
<main style={{ flexDirection: 'column', gap: '1rem' }}>
<CourseForm />
<CourseListCard title="강의 목록" items={items} onFavorite={handleFavoriteChange} />
{/* <CourseListCard title="관심 강의" items={favoriteItems} /> */}
</main>
);
}
export default App;
CourseListCard.jsx
강의 목록을 카드 형식으로 렌더링하며, 각 항목에 좋아요 버튼을 포함합니다.
import { Fragment } from 'react';
import Card from '../Card';
import CourseItem from './CourseItem';
function CourseListCard({ title, items, onFavorite }) {
const lastIndex = items.length - 1;
return (
<Card title={title}>
<div className="courses">
{items.map((item, index) => (
<Fragment key={item.id}>
<CourseItem {...item} onFavorite={onFavorite} />
{index !== lastIndex && <hr className="divider" />}
</Fragment>
))}
</div>
</Card>
);
}
export default CourseListCard;
CourseItem.jsx
강의 항목을 표시하며, 좋아요와 링크 버튼을 제공합니다.
function HeartIconBtn({ onClick, isFavorite = false }) {
return (
<button className="btn" onClick={(e) => onClick(e)}>
<img
className="btn__img"
src={isFavorite ? '/img/heart-fill-icon.svg' : '/img/heart-icon.svg'}
/>
</button>
);
}
function LinkIconBtn({ link }) {
return (
<a className="btn" href={link} target="_blank" rel="noreferrer">
<img className="btn__img" src="/img/link-icon.svg" />
</a>
);
}
export default function CourseItem({
id,
onFavorite,
title,
description,
thumbnail,
isFavorite,
link,
}) {
const handleFavorite = (e) => {
e.stopPropagation();
onFavorite(id, !isFavorite);
};
const handleItemClick = () => {
open(link, '_blank');
};
return (
<article className="course" onClick={handleItemClick}>
<img className="course__img" src={thumbnail} alt="강의 이미지" />
<div className="course__body">
<div className="course__title">{title}</div>
<div className="course__description">{description}</div>
</div>
<div className="course__icons">
<HeartIconBtn isFavorite={isFavorite} onClick={handleFavorite} />
{link && <LinkIconBtn link={link} />}
</div>
</article>
);
}
useImmer
로 구현 간소화React의 useImmer
를 활용하면 상태 관리가 더욱 간단해집니다.
draft
객체를 직접 수정하면 불변성을 유지하면서 새로운 상태를 생성합니다.
AppCourse.jsx
import './AppCourse.css';
import CourseForm from './components/course/CourseForm';
import CourseListCard from './components/course/CourseListCard';
import { useImmer } from 'use-immer';
function App() {
const [items, updateItems] = useImmer([
{
id: 0,
title: '입문자를 위한, HTML&CSS 웹 개발 입문',
description: '웹 개발에 필요한 기본 지식을 배웁니다.',
thumbnail: '/img/htmlcss.png',
isFavorite: false,
link: 'https://inf.run/JxyyT',
},
{
id: 1,
title: '입문자를 위한, ES6+ 최신 자바스크립트 입문',
description: '쉽고! 알찬! 내용을 준비했습니다.',
thumbnail: '/img/js.png',
isFavorite: true,
link: 'https://inf.run/Kpnd',
},
{
id: 2,
title: '포트폴리오 사이트 만들고 배포까지!',
description: '포트폴리오 사이트를 만들고 배포해 보세요.',
thumbnail: '/img/portfolio.png',
isFavorite: true,
link: 'https://inf.run/YkAN',
},
]);
const handleFavoriteChange = (id, isFavorite) => {
updateItems((draft) => {
const targetItem = draft.find((item) => item.id === id);
targetItem.isFavorite = isFavorite;
});
};
const favoriteItems = items.filter((item) => item.isFavorite);
return (
<main style={{ flexDirection: 'column', gap: '1rem' }}>
<CourseForm />
<CourseListCard title="강의 목록" items={items} onFavorite={handleFavoriteChange} />
{/* <CourseListCard title="관심 강의" items={favoriteItems} /> */}
</main>
);
}
export default App;
useState
를 사용해 상태를 직접 업데이트할 수 있지만, 불변성을 유지해야 합니다.useImmer
를 활용하면 상태를 보다 간단하고 직관적으로 업데이트할 수 있습니다.