자식 컴포넌트에
state와 setter를 같이 내려주는 법,useEffect의 의존성 배열, 배열find로 하나만 골라 보여주기, 그리고 클릭한 항목의 상세를 띄우는 게시판 구조까지 정리한 날.
Day.2에서 props로 값을 부모에서 자식으로 내려보내는 것까지 했다. 오늘은 값뿐 아니라 값을 바꾸는 함수(setter)도 props로 내려보내서 자식이 부모의 state를 바꾸게 만든다. 그 과정에서 쓰는 useEffect와 find도 같이 익혔다.
children= 태그와 태그 사이에 들어 있는 무언가
Day.2에서 본 props.children을 한 줄로 다시 정리했다. <Card>여기</Card>처럼 여는 태그와 닫는 태그 사이에 넣은 내용이 자식 컴포넌트에서 children이라는 이름으로 넘어온다.
자식(HateButton)이 버튼을 갖고, 부모(App)가 숫자를 갖는 구조다. 숫자(good, bad)는 부모의 state이고, 자식은 그걸 props로 받아서 보여주기만 하다가, 버튼을 누르면 부모가 내려준 setter를 호출한다.
// App.jsx (부모)
import React, { useEffect, useState } from 'react'
import HateButton from './HateButton'
const App = () => {
const [good, setGood] = useState(0)
const [bad, setBad] = useState(0)
useEffect(() => {
console.log('투표수가 변경되었습니다')
}, [good, bad])
// good, bad 둘 중 하나라도 바뀔 때마다 useEffect가 실행된다.
return (
<div>
{/* 좌측은 자식이 갖는 이름, 우측은 부모의 값. 그냥 이름=값 이라고 생각하면 된다. */}
<HateButton good={good} bad={bad} setGood={setGood} setBad={setBad} />
<br /><br />
좋아요:{good}<br />
싫어요:{bad}
</div>
)
}
export default App
// HateButton.jsx (자식)
import React from 'react'
const HateButton = ({ good, bad, setGood, setBad }) => {
return (
<div>
<button onClick={() => setGood(good + 1)}>👍{good}</button>
<button onClick={() => setBad(bad + 1)}>😡{bad}</button>
</div>
)
}
export default HateButton
흐름은 이렇다.
0이 표시된다.setGood을 호출하면 부모의 good 값이 바뀐다.App이 다시 렌더링되고, {good} 자리에 새 값이 반영된다. 자식에게 내려가는 good도 새 값이 된다.useEffect도 실행된다.직접 실행해서 좋아요 2번, 싫어요 1번을 눌러 보니 화면은 좋아요:2 싫어요:1이 되었다.
여기서 기억할 점은 state는 부모 한 곳에만 있다는 것이다. 자식은 자기만의 숫자를 따로 갖지 않고, 부모의 것을 빌려 쓴다. 이렇게 하면 부모가 두 숫자를 한꺼번에 알고 있어서 나중에 "좋아요 비율 계산" 같은 걸 부모에서 바로 할 수 있다. (이런 패턴을 흔히 "state 끌어올리기"라고 부른다.)
good={good}에서 왼쪽은 자식이 받을 props 이름, 오른쪽은 부모가 가진 변수다. 이름을 같게 맞춰 두면 자식에서 ({ good, bad, setGood, setBad })로 구조 분해해서 바로 쓸 수 있어서 편하다. 이름을 다르게 줘도 동작하지만 자식에서 받는 이름도 그에 맞춰야 한다.
useEffect(() => {
console.log('투표수가 변경되었습니다')
}, [good, bad])
배열을 어떻게 쓰느냐에 따라 실행 시점이 달라진다.
| 두 번째 인자 | 실행 시점 |
|---|---|
| 생략 | 렌더링될 때마다 |
[] | 처음 한 번만 |
[good, bad] | 처음 한 번 + good 또는 bad가 바뀔 때마다 |
필기에는 "바뀔 때마다 실행된다"고만 적었는데, 실제로 돌려 보면 처음 화면이 그려질 때도 한 번 실행된다.
effect good=0 bad=0 ← 첫 렌더링 직후 (아직 아무것도 안 눌렀다)
effect good=1 bad=0 ← 👍 첫 번째 클릭
effect good=2 bad=0 ← 👍 두 번째 클릭
effect good=2 bad=1 ← 😡 클릭
그래서 "투표수가 변경되었습니다"라는 메시지가 투표하기 전에도 한 번 찍힌다. 정말 "변경됐을 때만" 하고 싶다면 첫 실행인지 구분하는 처리를 따로 해야 한다.
또 하나, 개발 모드에서 <React.StrictMode>로 감싸져 있으면 첫 실행이 두 번 찍힌다. 이것도 실제로 확인했다. 개발 중에 부작용(effect)이 안전하게 짜여 있는지 검사하려고 React가 일부러 두 번 돌리는 것이고, 배포 빌드에서는 한 번만 실행된다. 로그가 두 번 찍힌다고 코드가 잘못된 게 아니다.
map이 배열 전체를 돌면서 새 배열을 만든다면, find는 조건에 맞는 첫 번째 요소 하나를 돌려준다.
const people = [
{ name: 'A', age: 25 },
{ name: 'B', age: 20 },
{ name: 'C', age: 11 },
{ name: 'D', age: 63 },
]
const n = people.find((p) => p.age > 20)
console.log(n) // { name: 'A', age: 25 }
나이가 20 초과인 사람은 A와 D 두 명이지만, find는 처음 찾은 A에서 멈춘다. 둘 다 필요하면 filter를 쓴다.
people.filter((p) => p.age > 20) // [{A}, {D}] 조건에 맞는 전부를 배열로
숫자 배열로도 해 보았다.
const arr = [1, 3, 5, 7]
arr.find((a) => a > 10) // undefined ← 조건에 맞는 게 없으면 undefined
arr.find((a) => a <= 5) // 1 ← 5 이하 중 '첫 번째'. 1, 3, 5가 다 해당되지만 1에서 멈춘다
arr.findIndex((a) => a > 10) // -1 ← 위치를 알려주는 findIndex는 못 찾으면 -1
주의할 점은 못 찾으면 undefined가 나온다는 것이다. 이 값을 그대로 undefined.name처럼 쓰면 에러가 나므로, 찾은 결과가 없을 수도 있는 상황에서는 확인을 한 번 거쳐야 한다. (5번에서 selPost1 && ...로 확인하는 것도 같은 이유다.)
필기의 콜백 변수 이름이 바깥 배열과 같은
pro/pro1식이라 헷갈릴 수 있어서, 위 예시는 콜백 변수를p로 짧게 바꿨다. 바깥 변수와 같은 이름을 쓰면 안쪽이 바깥을 가려서(shadowing) 읽기 어려워진다.
사용자 목록 버튼을 누르면 그 사람의 카드가 아래에 표시되는 예제다. 저장하는 것은 객체가 아니라 id 하나다.
// App.jsx
import React, { useState } from 'react'
import ProCard from './ProCard'
const App = () => {
const user = [
{ id: 1, name: '사용자A', age: 14, job: '개발자' },
{ id: 2, name: '사용자B', age: 22, job: '교수' },
{ id: 3, name: '사용자C', age: 33, job: '디자이너' },
{ id: 4, name: '사용자D', age: 41, job: '경비원' },
]
const [selectUserId, setSelectUserId] = useState(1)
// 내가 클릭한 id와 user의 id가 같은 사람을 찾는다.
const selUser = user.find((u) => u.id === selectUserId)
return (
<div>
{user.map((a) => {
return (
<button key={a.id} onClick={() => setSelectUserId(a.id)}>
{a.name}
</button>
)
})}
<ProCard user1={selUser} />
</div>
)
}
export default App
// ProCard.jsx
import React from 'react'
const ProCard = ({ user1 }) => {
return (
<div>
<h1>{user1.name} {user1.age} {user1.job}</h1>
</div>
)
}
export default ProCard
실행해 보면 처음에는 첫 사람의 카드가 뜨고, 세 번째 버튼을 누르면 카드가 그 사람 것으로 바뀐다.
(처음) 사용자A 14 개발자
(3번째 버튼 클릭) 사용자C 33 디자이너
구조를 정리하면 이렇다.
find로 id에 해당하는 실제 객체를 찾아서 자식에게 넘긴다.ProCard)은 받은 객체만 보여준다.id만 저장하면 사용자 목록의 값이 바뀌어도 선택된 사람의 최신 정보를 항상 find로 다시 얻는다. 객체를 통째로 state에 복사해 두면 원본이 바뀌었을 때 복사본은 옛날 값을 들고 있게 될 수 있다.
이 코드에는 한 가지 가정이 있다. ProCard는 user1이 반드시 있다고 믿고 user1.name을 읽는다. 지금은 초기값이 1이고 버튼도 목록에서 만들어서 항상 찾아지지만, 만약 목록에 없는 id가 들어가면 find가 undefined를 돌려주고 ProCard에서 에러가 난다. 실제 서비스라면 {selUser && <ProCard user1={selUser} />}처럼 막아 둬야 한다.
목록에서 제목을 클릭하면 아래에 내용이 나오는 간단한 게시판이다.
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)
// 처음에는 null. 세번째 제목을 클릭하면 세번째 객체를 가져와서 selPost1에 저장한다.
// 다른 글을 클릭할 때마다 selPost1이 그 글의 객체로 바뀐다.
const selPost = (post) => {
setSelPost1(post) // 클릭한 객체
}
return (
<div className="board-app">
<h1>Board</h1>
<div className="board-li">
<input placeholder="제목 입력" /><br />
<textarea placeholder="내용 입력" />
<h2>Board List</h2>
{posts.map((x) => {
return (
<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
실행해 보면 처음에는 상세 영역이 없고(null && ...은 아무것도 그리지 않는다), 세 번째 글을 누르면 세번째 제목 / 세번째 내용, 첫 번째 글을 누르면 그 글로 바뀐다.
여기서 쓴 두 가지 패턴이 오늘 내용의 핵심이다.
useState(null)로 "아직 선택 없음"을 표현하고, {selPost1 && (...)}로 선택됐을 때만 그린다. Day.2의 조건부 렌더링이다. 객체는 항상 truthy이고 null은 falsy라서 0 && 같은 함정도 없다.map으로 목록을 만들고, 각 항목에 onClick으로 "내가 누구인지"를 넘긴다. onClick={() => selPost(x)}처럼 화살표 함수로 감싸서 클릭했을 때 호출되게 한다. onClick={selPost(x)}라고 쓰면 렌더링 중에 바로 호출되어 버린다.위 코드는 클릭한 객체 자체를 state에 저장한다. 5번처럼 id만 저장하고 find로 찾는 방식으로 바꾸면 이런 차이가 생긴다. 글을 삭제하는 기능이 있다고 해 보자.
const [selId, setSelId] = useState(null)
const sel = posts.find((p) => p.id === selId) // 목록에서 찾는다
// 삭제: filter로 해당 id만 빼고 새 배열을 만든다
const del = (id) => setPosts(posts.filter((p) => p.id !== id))
{sel && <div>{sel.title}/{sel.content}</div>}
글을 선택한 상태에서 그 글을 삭제하면, 목록에서 사라졌으니 find가 undefined를 돌려주고 상세 영역도 같이 사라진다. 실제로 돌려 보니 삭제 후 목록이 1개로 줄고 상세 영역은 없어졌다. 객체를 직접 저장했다면 목록에서는 지워졌는데 상세 영역에는 삭제된 글이 계속 남아 있었을 것이다. 지금 예제는 삭제가 없어서 둘 다 문제없지만, 기능이 늘어나면 id 방식이 덜 꼬인다.
입력창(input, textarea)은 모양만 있고 state와 연결되어 있지 않다. 입력해도 글이 추가되지 않는다. Day.2에서 한 제어 컴포넌트(value와 onChange)와 새 배열을 만들어 추가하는 패턴(setPosts([...posts, 새글]))을 합치면 글쓰기까지 만들 수 있다. 이건 다음에 이어서 해 볼 부분이다.
useEffect(함수, [값들])은 처음 한 번 + 배열 안의 값이 바뀔 때마다 실행된다. 바뀔 때만 실행되는 게 아니라 처음에도 실행되고, 개발 모드의 StrictMode에서는 처음에 두 번 실행된다.find는 조건에 맞는 첫 번째 하나를 돌려주고, 못 찾으면 undefined다. 전부 필요하면 filter를 쓴다.find로 찾으면 원본이 바뀌어도 최신 값을 쓴다.null을 "선택 없음"으로 쓰고 {값 && (...)}으로 있을 때만 그린다. find 결과는 undefined일 수 있어서 쓰기 전에 확인한다.Tags: React props useState useEffect find 조건부렌더링 프론트엔드 개발자 학습기록