배열 state에 항목을 추가·삭제·수정하는 패턴을 게시판, 할 일 목록, 회원 폼으로 반복해서 익힌 날. 핵심은 state를 직접 건드리지 않고 새 배열을 만들어서
set에 넣는 것이다.
Day.3에서 게시판의 목록과 상세 보기까지 만들었다. 오늘은 거기에 추가, 삭제, 수정을 붙인다. 코드는 길어졌지만 쓰는 패턴은 세 가지로 정리된다.
| 하고 싶은 일 | 쓰는 방법 | 새 배열을 만드는 도구 |
|---|---|---|
| 추가 | setList([...list, 새항목]) | 스프레드 ... |
| 삭제 | setList(list.filter(x => x.id !== id)) | filter |
| 수정 | setList(list.map(x => x.id === id ? {...x, 값} : x)) | map + 객체 스프레드 |
push가 아니라 set배열 추가는
set변수명([...변수명, 새로넣을값])이렇게 한다.
JavaScript의배열명.push(새값)은 배열의 주소가 그대로라서 다시 렌더링되지 않는다.
기존 값의 주소가 바뀌어야 React가 다시 렌더링하기 때문이다.
필기의 이 내용을 직접 실행해서 확인했다. 버튼을 누르면 push로 배열에 값을 넣고 setPosts(posts)를 호출하는 컴포넌트다.
const [posts, setPosts] = useState([1, 2])
const add = () => { posts.push(posts.length + 1); setPosts(posts) }
// 버튼 클릭 후 화면: 1,2 ← 배열 안에는 3이 들어갔지만 화면은 그대로
push는 같은 배열 객체의 내용만 바꾼다. React는 setPosts에 넘어온 값이 이전 state와 같은 객체(같은 주소)인지 먼저 비교하고, 같으면 "바뀐 게 없다"고 보고 렌더링을 건너뛴다. 그래서 화면이 안 바뀐다.
스프레드를 쓰면 내용은 복사하고 주소는 새로 만든다.
setPosts([...posts, newPost])
// 기존 posts를 펼쳐서 복사하고, newPost를 마지막에 붙여 '새 배열'을 만든다.
// 기존 게시글은 그대로 유지되고, 추가만 된다.
Day.3의 게시판에 제목/내용 입력창과 등록 버튼을 붙였다. 입력값은 Day.2에서 한 제어 컴포넌트(value + onChange)로 관리한다.
import React, { useState } from 'react'
const App = () => {
const [posts, setPosts] = useState([
{ id: 1, title: '첫번째 제목', content: '첫번째 내용' },
{ id: 2, title: '두번째 제목', content: '두번째 내용' },
{ id: 3, title: '세번째 제목', content: '세번째 내용' },
])
const [selPost1, setSelPost1] = useState(null)
const selPost = (post) => { setSelPost1(post) } // 클릭한 객체
// 새 게시글 제목 / 내용을 관리하는 state
const [newTitle, setNewTitle] = useState('')
const [newContent, setNewContent] = useState('')
const addPost = () => {
if (newTitle.trim() && newContent.trim()) { // 공백만 있는 입력은 걸러낸다
const newPost = {
id: posts.length + 1,
title: newTitle, // 입력한 제목이 newTitle에 저장되어 있다
content: newContent,
}
// setPosts가 호출되면 React가 이전 state와 새 값을 비교한다.
// 새로 만든 배열이라 주소가 다르므로 → 리렌더링 → 화면이 바뀐다.
setPosts([...posts, newPost])
// 등록하고 나면 입력창을 비운다
setNewTitle('')
setNewContent('')
}
}
return (
<div className="board-app">
<h1>Board</h1>
<div className="board-li">
<input placeholder="제목 입력" value={newTitle}
onChange={(e) => setNewTitle(e.target.value)} /><br />
<textarea placeholder="내용 입력" value={newContent}
onChange={(e) => setNewContent(e.target.value)} />
<button onClick={addPost}>게시글 등록</button>
<h2>Board List</h2>
{posts.map((x) => (
<div key={x.id} className="board-item" onClick={() => selPost(x)}>
<h2>{x.title}</h2>
</div>
))}
</div>
<hr />
{selPost1 && (
<div>
<h2>{selPost1.title}</h2>
<h2>{selPost1.content}</h2>
</div>
)}
</div>
)
}
export default App
실행해 보니 이렇게 동작했다.
trim() 검사에서 걸러진다)""로 비워진다trim()은 문자열 앞뒤 공백을 지운다. 스페이스만 누른 입력은 trim()하면 빈 문자열이 되어 falsy라서 if를 통과하지 못한다.
filter로 삭제 기능을 붙였다. 삭제할 id와 다른 글만 남기면 그게 곧 삭제다.
const deletePost = (id) => {
// 클릭한 id와 일치하지 않는 게시글만 남긴다 → 일치하는 글은 사라진다
setPosts(posts.filter((post1) => id !== post1.id))
}
그런데 문제가 생긴다. 글을 클릭해서 상세를 보고 있는 상태에서 그 글을 삭제하면, 목록에서는 사라졌는데 아래 상세 영역에는 계속 남아 있다. selPost1이 삭제된 객체를 그대로 들고 있기 때문이다. 이걸 막으려고 useEffect를 썼다.
useEffect(() => {
// some: 조건을 만족하는 요소가 '하나라도' 있으면 true
// 현재 목록 안에 선택한 글과 id가 같은 글이 없다면(= !some) → 선택을 null로 되돌린다
if (selPost1 && !posts.some((post) => post.id === selPost1.id)) {
setSelPost1(null)
}
}, [posts, selPost1])
Day.3에서 배운 의존성 배열이 여기서 쓰인다. posts나 selPost1이 바뀔 때마다 "선택한 글이 아직 목록에 있나?"를 검사해서, 없으면 선택을 해제한다. 삭제 버튼은 제목 옆에 붙였다.
<h2>{x.title} <button onClick={() => { deletePost(x.id) }}>삭제</button></h2>
필기에는 key를 왜 쓰는지도 적어 두었다. React가 배열을 렌더링할 때 각 항목을 식별해서, 상태가 바뀔 때 어떤 항목이 바뀌고 추가되고 삭제됐는지 효율적으로 파악해 DOM 업데이트를 최적화하기 위해서다. 맞는 설명이다.
선택한 글을 삭제하면 상세가 사라지는 것도 직접 확인했다. 그런데 이 코드를 돌려 보면서 필기에 없던 문제가 두 가지 보였다.
삭제 버튼은 글 하나를 감싸는 <div onClick={() => selPost(x)}> 안쪽에 있다. 그래서 삭제 버튼을 눌러도 클릭 이벤트가 위로 전달(버블링)되어 바깥 div의 onClick까지 실행된다. 결과는 이렇다.
T1글을 열어 둔 상태에서T2의 삭제를 누르면 →T2가 삭제되고, 동시에 "T2를 선택"하는onClick이 실행된다 →useEffect가 "선택한 글이 목록에 없네" 하고 선택을 해제한다 → 엉뚱하게T1의 상세도 같이 사라진다.
실제로 T1을 열어 둔 채 T2를 삭제했더니 상세 영역이 사라졌다. 삭제 버튼에서 이벤트가 위로 올라가지 않게 막으면 된다.
<button onClick={(e) => { e.stopPropagation(); deletePost(x.id) }}>삭제</button>
id: posts.length + 1은 글이 삭제되지 않을 때만 안전하다. 1, 2, 3 중에서 1을 지우면 남은 id는 2, 3인데 길이는 2라서, 새 글의 id가 3이 되어 기존 글과 id가 겹친다. 실제로 삭제 뒤에 새 글을 등록해 보니 "같은 key가 있다"는 React 경고가 떴다. key가 겹치면 항목을 제대로 식별하지 못한다.
그래서 뒤에 나오는 할 일 목록에서는 Date.now()를 id로 썼다. 지금 시각의 밀리초 숫자라서 길이에 상관없이 겹칠 일이 거의 없다. (조금 더 단단하게 하려면 crypto.randomUUID()나 "다음에 쓸 id"를 별도 state로 두는 방법도 있다.)
useEffect로 "지워졌으면 해제"를 하는 대신, Day.3에서 본 것처럼 선택한 글의 id만 저장하고 find로 찾으면 지워진 순간 find가 undefined를 돌려줘서 useEffect 없이도 상세가 사라진다. 오늘은 useEffect 쓰는 연습을 한 셈이고, 실무에서는 가능하면 계산할 수 있는 값은 useEffect 없이 렌더링 중에 계산하는 쪽이 더 단순하다.
입력창과 추가 버튼은 자식(Add)에, 목록 state는 부모(App)에 두었다. 자식은 입력값만 들고 있다가 버튼을 누르면 부모가 내려준 함수를 호출해서 값을 올려보낸다.
// App.jsx (부모)
import React, { useState } from 'react'
import Add from './components/Add'
const copy = [
{ id: 1, done: false, content: '내용1' },
{ id: 2, done: false, content: '내용2' },
{ id: 3, done: false, content: '내용3' },
]
const App = () => {
const [todo, setTodo] = useState(copy)
const onAdd = (content) => {
const newTodo = { id: todo.length + 1, done: false, content }
// 기존 배열의 복사본 + newTodo → 새 배열이 만들어진다
setTodo([...todo, newTodo])
}
return (
<div>
<Add onAdd={onAdd} />
{todo.map((i) => <div key={i.id}>{i.content}</div>)}
</div>
)
}
export default App
// components/Add.jsx (자식)
import React, { useState } from 'react'
const Add = ({ onAdd }) => {
const [content, setContent] = useState('')
const onChContent = (e) => {
setContent(e.target.value) // 입력한 값이 content에 저장된다
}
const onAdd2 = () => {
// 추가 버튼을 클릭했을 때 부모의 onAdd를 호출하면서 값을 올려보낸다
onAdd(content)
}
return (
<div>
<input value={content} onChange={onChContent} placeholder="할 일 입력" />
<button onClick={onAdd2}>추가</button>
</div>
)
}
export default Add
Day.3에서는 값과 setter를 내려보내서 자식이 직접 바꿨다. 이번에는 "추가"라는 동작을 하는 함수 onAdd를 내려보내고, 자식은 그 함수에 데이터를 인자로 넘기는 방식이다. 데이터는 자식에서 부모로 올라가고, 부모가 자기 state를 바꾼다. 부모의 state를 부모만 바꾸게 하고 싶을 때 이쪽이 더 깔끔하다.
필기에는 Add를 두 가지로 적어 두었다. 하나는 <input value={content} ...>이고, 다른 하나는 value 없이 onChange만 있다. 둘을 실행해 비교해 보니 추가는 둘 다 잘 되지만, 추가한 뒤 입력창이 비워지지 않는다. 두 버전 모두 입력창에 "new"가 그대로 남아 있었다. onAdd(content) 다음에 setContent('')가 없기 때문이다. value가 없는 쪽은 아예 React가 값을 관리하지 않는 비제어 입력이라서, 비우려면 value={content}로 바꾸고 setContent('')까지 넣어야 한다.
const onAdd2 = () => {
onAdd(content)
setContent('') // 추가한 뒤 입력창 비우기
}
수정 기능의 핵심은 어떤 항목을 수정 중인지를 state(modiId)로 기억하고, 그에 따라 화면을 바꾸는 것이다.
import React, { useState } from 'react'
const App = () => {
const [todo, setTodo] = useState([
{ id: 1, msg: '공부' },
{ id: 2, msg: '운동' },
])
const [modiId, setModiId] = useState(null) // 수정 중인 항목의 id (없으면 null)
const [modiMsg, setModiMsg] = useState('') // 수정창에 입력 중인 값
const onModify = (id, msg) => {
setModiId(id) // 수정 버튼을 누르면 그 항목의 id를 저장 (예: modiId = 1)
setModiMsg(msg) // 현재 내용을 수정창에 채워 넣는다
}
const saveClick = (id) => {
// 데이터를 고쳐서 배열을 새로 만든다: 같은 id만 바꾸고 나머지는 그대로
setTodo(todo.map((todo1) => todo1.id === id ? { ...todo1, msg: modiMsg } : todo1))
setModiId(null) // 저장하면 수정 모드를 끝낸다
setModiMsg('')
}
return (
<div>
<ul>
{todo.map((todo1) => (
<li key={todo1.id}>
{todo1.id === modiId ? (
// 수정 중인 항목이면 입력창과 저장 버튼
<>
<input value={modiMsg} onChange={(e) => setModiMsg(e.target.value)} />
<button onClick={() => saveClick(todo1.id)}>저장</button>
</>
) : (
// 아니면 내용과 수정 버튼
<>
{todo1.msg}
<button onClick={() => onModify(todo1.id, todo1.msg)}>수정</button>
</>
)}
</li>
))}
</ul>
</div>
)
}
export default App
두 번째 항목의 수정을 눌러 보니 입력창에 기존 값 운동이 채워졌고, 값을 바꿔 저장하자 해당 항목만 바뀌었다.
여기서 쓴 도구를 정리하면 이렇다.
{ ...todo1, msg: modiMsg }: 객체 스프레드. 기존 객체의 모든 속성을 복사하고 msg만 새 값으로 덮어쓴 새 객체를 만든다. 객체도 배열처럼 직접 고치지 않고 새로 만든다.todo.map(...)으로 같은 id인 항목만 새 객체로 바꾸고 나머지는 그대로 돌려주면, 결과는 일부만 바뀐 새 배열이다.<>...</>(Fragment)로 감싸서 불필요한 div 없이 여러 요소를 하나의 묶음으로 반환한다.modiId가 null인지 아닌지가 화면 분기 기준이다. Day.2의 삼항 연산자 조건부 렌더링이다.필기에는 같은 내용을 내가 쓴 버전과 강사님 버전으로 나란히 적어 두었는데, 구조는 같고 변수명(x/todo1, modMsg/modiMsg)만 달랐다. 위 코드는 변수 이름이 더 읽기 좋은 쪽으로 정리했다.
입력창 하나와 버튼 하나로 추가와 수정을 겸하는 버전도 만들었다. modiId가 null이면 추가 모드, 값이 있으면 수정 모드다.
import React, { useState } from 'react'
const App = () => {
// 1. 입력창 state
const [text, setText] = useState('')
// 2. 목록 state
const [list, setList] = useState([
{ id: 1, do: '운동' },
{ id: 2, do: '공부' },
{ id: 3, do: '독서' },
])
// 3. 수정 상태 (null이면 추가 모드)
const [modiId, setModiId] = useState(null)
const onAddUpdate = () => {
if (modiId !== null) {
// 수정 모드: 같은 id만 새 내용으로 바꾼다
setList(list.map((x) => (x.id === modiId ? { ...x, do: text } : x)))
setModiId(null)
setText('')
} else {
// 추가 모드: 새 항목을 끝에 붙인다
setList([...list, { id: Date.now(), do: text }])
setText('')
}
}
const onModify = (x) => { // 수정 버튼: 수정 모드로 전환하고 입력창에 기존 값을 채운다
setModiId(x.id)
setText(x.do)
}
const onDelete = (id) => {
setList(list.filter((x) => x.id !== id))
}
return (
<div>
<h1>오늘 할 일</h1>
<input value={text} placeholder="할 일 입력" onChange={(e) => setText(e.target.value)} />
<button onClick={onAddUpdate}>{modiId !== null ? '수정완료' : '추가'}</button>
<hr />
<ul>
{list.map((x) => (
<li key={x.id}>
{x.do}
<button onClick={() => onModify(x)}>수정</button>
<button onClick={() => onDelete(x.id)}>삭제</button>
</li>
))}
</ul>
</div>
)
}
export default App
modiId가 null) → 버튼 글자는 추가modiId에 id가 들어감) → 입력창에 기존 값이 채워지고 버튼 글자가 수정완료로 바뀐다실행해서 수정 버튼을 누르면 입력창에 운동이 채워지고 버튼이 수정완료가 되었다. 값을 고쳐서 누르면 그 항목만 바뀌고 버튼은 추가로 돌아왔다. 삭제도 정상이었다.
이 코드에서 아쉬운 점은 빈 입력도 추가된다는 것이다. 입력창을 비운 채 추가를 누르면 빈 항목이 생겼다. 2번의 trim() 검사를 넣어서 if (!text.trim()) return을 맨 앞에 두면 막을 수 있다.
이름과 이메일 두 칸을 받아서 목록으로 보여주고 수정·삭제도 되는 폼이다. 6번과 같은 구조에 입력 검증이 더해졌다.
import React, { useState } from 'react'
const App = () => {
const [name, setName] = useState('')
const [mail, setMail] = useState('')
const [edit, setEdit] = useState(null) // 수정 중인 id (null이면 추가 모드)
const [list, setList] = useState([])
const add = () => {
if (edit !== null) { // 수정 모드 → 저장
if (mail.includes('@')) {
setList(list.map((i) => (edit === i.id ? { ...i, name, mail } : i)))
setName(''); setMail(''); setEdit(null)
} else {
alert('이메일에 @ 문자가 들어가야 합니다.')
}
} else { // 추가 모드
if (mail.includes('@')) {
setList([...list, { id: Date.now(), name, mail }])
setName(''); setMail('')
} else {
alert('이메일에 @ 문자가 들어가야 합니다.')
}
}
}
const del_btn = (id) => setList(list.filter((i) => i.id !== id))
const edit_btn = (x) => {
setEdit(x.id); setName(x.name); setMail(x.mail)
}
return (
<div>
<input value={name} placeholder="이름" onChange={(e) => setName(e.target.value)} />
<input value={mail} placeholder="이메일" onChange={(e) => setMail(e.target.value)} />
<button onClick={add}>{edit !== null ? '저장' : '추가'}</button>
<hr />
<div>
{list.map((x) => (
<p key={x.id}>
이름:{x.name}, 이메일:{x.mail}
<button onClick={() => edit_btn(x)}>수정</button>
<button onClick={() => del_btn(x.id)}>삭제</button>
</p>
))}
</div>
</div>
)
}
export default App
mail.includes('@'): 문자열에 @가 들어 있으면 true. 없으면 alert로 알리고 추가/저장을 하지 않는다.{ id, name, mail }처럼 변수명과 키 이름이 같을 때 한 번만 쓰기)를 쓰면 코드가 짧아진다.@ 없이 입력했을 때 경고가 한 번 뜨고 목록에는 추가되지 않았고, @가 있으면 추가, 수정해서 저장하면 반영되었다. 다만 includes('@')는 아주 느슨한 검사다. 이메일 칸에 @ 한 글자만 넣어도 통과했다. 이름 칸이 비어 있어도 통과한다. 연습용으로는 충분하지만, 실제 서비스라면 정규식(/^[^\s@]+@[^\s@]+\.[^\s@]+$/ 같은 형태)이나 <input type="email">의 기본 검증을 같이 쓴다.
set에 넣는다. push는 같은 배열이라 화면이 안 바뀐다.filter, 수정은 map + 객체 스프레드다.null이면 평소 상태)하고, 그 값으로 화면을 분기한다. 한 입력창으로 추가와 수정을 겸할 수도 있다.e.stopPropagation()). id는 length + 1 대신 Date.now() 같은 겹치지 않는 값을 쓴다.useEffect로도 풀 수 있지만, id만 저장하고 find로 찾으면 더 단순하게 해결된다.Tags: React useState 배열state CRUD 불변성 useEffect props 프론트엔드 개발자 학습기록