import { useState } from 'react';
import './ReviewForm.css';
function ReviewForm() {
const [title, setTitle] = useState('');
const [rating, setRating] = useState(0);
const [content, setContent] = useState('');
const handleTitleChange = (e) => {
setTitle(e.target.value);
};
const handleRatingChange = (e) => {
const nextRating = Number(e.target.value);
setRating(nextRating);
};
const handleContentChange = (e) => {
setContent(e.target.value);
};
return (
<form className="ReviewForm">
<input value={title} onChange={handleTitleChange} />
<input type="number" value={rating} onChange={handleRatingChange} />
<textarea value={content} onChange={handleContentChange} />
</form>
);
}
export default ReviewForm;
const handleSubmit = (e) => {
e.preventDefault();
console.log({
title,
rating,
content,
});
};
return (
<form className="ReviewForm" onSubmit={handleSubmit}>
<input value={title} onChange={handleTitleChange} />
<input type="number" value={rating} onChange={handleRatingChange} />
<textarea value={content} onChange={handleContentChange} />
<button type="submit">확인</button>
</form>
);
}
import { useState } from 'react';
import './ReviewForm.css';
function ReviewForm() {
const [values, setValues] = useState({
title: '',
rating: 0,
content: '',
});
const handleChange = (e) => {
const { name, value } = e.target;
setValues((prevValues) => ({
...prevValues,
[name]: value,
}));
};
const handleSubmit = (e) => {
e.preventDefault();
console.log(values);
};
return (
<form className="ReviewForm" onSubmit={handleSubmit}>
<input name="title" value={values.title} onChange={handleChange} />
<input type="number" name="rating" value={values.rating} onChange={handleChange} />
<textarea name="content" value={values.content} onChange={handleChange} />
<button type="submit">확인</button>
</form>
);
}
export default ReviewForm;
제어 컴포넌트 (Controlled Component)
인풋의 value 값을 리액트에서 지정한다. 리액트에서 사용하는 값과 실제 인풋 갑이 항상 일치한다. 주로 권장되는 방법이다.
비제어 컴포넌트 (Uncontrolled Component)
인풋의 value 값을 리액트에서 지정하지 않는다. 경우에 따라서 필요할 때가 있다.
리액트에선 순수 HTML과 다르게 onChange Prop을 사용하면 입력 값이 바뀔 때마다 핸들러 함수를 실행한다. oninput 이벤트와 같아고 생각하면 된다.
<lavel /> 태그에서 사용하는 속성인 for는 자바스크립트 반복문 키워드인 for와 겹치기 때문에 리액트에서는 htmlFor를 사용한다.
스테이트를 만들고 target.value 값을 사용해서 값을 변경해 줄 수 있었다. 이때 value Prop으로 스테이트 값을 내려주고, onChange Prop으로 핸들러 함수는 넘겨주었다.
function TripSearchForm() {
const [location, setLocation] = useState('Seoul');
const [checkIn, setCheckIn] = useState('2022-01-01');
const [checkOut, setCheckOut] = useState('2022-01-02');
const handleLocationChange = (e) => setLocation(e.target.value);
const handleCheckInChange = (e) => setCheckIn(e.target.value);
const handleCheckOutChange = (e) => setCheckOut(e.target.value);
return (
<form>
<h1>검색 시작하기</h1>
<label htmlFor="location">위치</label>
<input id="location" name="location" value={location} placeholder="어디로 여행가세요?" onChange={handleLocationChange} />
<label htmlFor="checkIn">체크인</label>
<input id="checkIn" type="date" name="checkIn" value={checkIn} onChange={handleCheckInChange} />
<label htmlFor="checkOut">체크아웃</label>
<input id="checkOut" type="date" name="checkOut" value={checkOut} onChange={handleCheckOutChange} />
<button type="submit">검색</button>
</form>
)
}
이벤트 객체인 target.name과 target.value 값을 사용해서 값을 변경해 줄 수도 있다.
function TripSearchForm() {
const [values, setValues] = useState({
location: 'Seoul',
checkIn: '2022-01-01',
checkOut: '2022-01-02',
})
const handleChange = (e) => {
const { name, value } = e.target;
setValues((prevValues) => ({
...prevValues,
[name]: value,
}));
}
return (
<form>
<h1>검색 시작하기</h1>
<label htmlFor="location">위치</label>
<input id="location" name="location" value={values.location} placeholder="어디로 여행가세요?" onChange={handleChange} />
<label htmlFor="checkIn">체크인</label>
<input id="checkIn" type="date" name="checkIn" value={values.checkIn} onChange={handleChange} />
<label htmlFor="checkOut">체크아웃</label>
<input id="checkOut" type="date" name="checkOut" value={values.checkOut} onChange={handleChange} />
<button type="submit">검색</button>
</form>
)
}
HTML 폼의 기본 동작은 submit 타입의 버튼을 눌렀을 때 페이지를 이동한다. 이벤트 객체의 preventDefault를 사용하면 이 동작을 막을 수 있다.
const handleSubmit = (e) => {
e.preventDefault();
// ...
}
인풋 태그의 value 속성을 지정하고 사용하는 컴포넌트이다. 리액트에서 인풋의 값을 제어하는 경우로 리액트에서 지정한 값과 실제 인풋 value의 값이 항상 같다. 이렇게 하면 값을 예측하기가 쉽고 인풋에 쓰는 값을 여러 군데서 쉽게 바꿀 수 있다는 장점이 있어서 리액트에서 권장하는 방법이다. 이때 State냐 Prop이냐는 중요하지 않고, 리액트로 value를 지정한다는 것이 핵심이다.
function TripSearchForm() {
const [values, setValues] = useState({
location: 'Seoul',
checkIn: '2022-01-01',
checkOut: '2022-01-02',
})
const handleChange = (e) => {
const { name, value } = e.target;
setValues((prevValues) => ({
...prevValues,
[name]: value,
}));
}
return (
<form>
<h1>검색 시작하기</h1>
<label htmlFor="location">위치</label>
<input id="location" name="location" value={values.location} placeholder="어디로 여행가세요?" onChange={handleChange} />
<label htmlFor="checkIn">체크인</label>
<input id="checkIn" type="date" name="checkIn" value={values.checkIn} onChange={handleChange} />
<label htmlFor="checkOut">체크아웃</label>
<input id="checkOut" type="date" name="checkOut" value={values.checkOut} onChange={handleChange} />
<button type="submit">검색</button>
</form>
)
}
function TripSearchForm({ values, onChange }) {
return (
<form>
<h1>검색 시작하기</h1>
<label htmlFor="location">위치</label>
<input id="location" name="location" value={values.location} placeholder="어디로 여행가세요?" onChange={onChange} />
<label htmlFor="checkIn">체크인</label>
<input id="checkIn" type="date" name="checkIn" value={values.checkIn} onChange={onChange} />
<label htmlFor="checkOut">체크아웃</label>
<input id="checkOut" type="date" name="checkOut" value={values.checkOut} onChange={onChange} />
<button type="submit">검색</button>
</form>
)
}
인풋 태그의 value 속성을 리액트에서 지정하지 않고 사용하는 컴포넌트이다.
function TripSearchForm({ onSubmit }) {
return (
<form onSubmit={onSubmit} >
<h1>검색 시작하기</h1>
<label htmlFor="location">위치</label>
<input id="location" name="location" placeholder="어디로 여행가세요?" />
<label htmlFor="checkIn">체크인</label>
<input id="checkIn" type="date" name="checkIn" />
<label htmlFor="checkOut">체크아웃</label>
<input id="checkOut" type="date" name="checkOut" />
<button type="submit">검색</button>
</form>
)
}
참고로 위처럼 작성해도 onSubmit 함수에서는 폼 태그를 참조할 수 있다. 값들을 참조하려면 이벤트 객체의 target을 활용해도 된다.
const handleSubmit = (e) => {
e.preventDefault();
const form = e.target;
const location = form['location'].value;
const checkIn = form['checkIn'].value;
const checkOut = form['checkOut'].value;
// ....
}
폼 태그로 곧바로 FormData를 바로 만드는 것도 가능하다.
const handleSubmit = (e) => {
e.preventDefault();
const form = e.target;
const formData = new FormData(form);
// ...
}
만약 이렇게 제어 컴포넌트랑 비제어 컴포넌트 모두 쓸 수 있는 경우라면 제어 컴포넌트를 사용하는 걸 추천한다.
리액트에서 파일 인풋은 반드시 비제어 컴포넌트로 만들어야 한다.
function FileInput({ name, value, onChange }) {
const handleChange = (e) => {
const nextValue = e.target.files[0];
onChange(name, nextValue);
};
return <input type="file" onChange={handleChange} />;
}
export default FileInput;
원하는 시점에 실제 DOM 노드에 접근할 때 사용한다.
import { useEffect, useRef } from 'react';
function FileInput({ name, value, onChange }) {
const inputRef = useRef();
const handleChange = (e) => {
const nextValue = e.target.files[0];
onChange(name, nextValue);
};
return <input type="file" onChange={handleChange} ref={inputRef} />;
}
export default FileInput;
import { useRef } from 'react';
function FileInput({ name, value, onChange }) {
const inputRef = useRef();
const handleChange = (e) => {
const nextValue = e.target.files[0];
onChange(name, nextValue);
};
const handleClearClick = () => {
const inputNode = inputRef.current;
if (!inputNode) return;
inputNode.value = '';
onChange(name, null);
};
return (
<div>
<input type="file" onChange={handleChange} ref={inputRef} />
{value && <button onClick={handleClearClick}>X</button>}
</div>
);
}
export default FileInput;
앞에서 DOM 노드를 참조할 때 useRef 함수로 Ref 객체를 만들고 이것의 current라는 프로퍼티를 활용했다.
import { useRef } from 'react';
// ...
const ref = useRef();
ref Prop 사용하기const ref = useRef();
// ...
<div ref={ref}> ... </div>
ref Prop에다가 앞에서 만든 Ref 객체를 내려주면 된다.
const node = ref.current;
if (node) {
// node 를 사용하는 코드
}
Ref 객체의 current라는 프로퍼티를 사용하면 DOM 노드를 참조할 수 있다. current 값은 없을 수도 있으니까 반드시 값이 존재하는지 검사하고 사용해야 한다.
img 노드의 크기를 ref를 활용해서 출력할 것이다. img 노드에는 너비 값인 width와 높이 값인 height라는 속성이 있다.
import { useRef } from 'react';
function Image({ src }) {
const imgRef = useRef();
const handleSizeClick = () => {
const imgNode = imgRef.current;
if (!imgNode) return;
const { width, height } = imgNode;
console.log(`${width} x ${height}`);
};
return (
<div>
<img src={src} ref={imgRef} alt="크기를 구할 이미지" />
<button onClick={handleSizeClick}>크기 구하기</button>
</div>
);
}
import { useEffect, useRef, useState } from 'react';
function FileInput({ name, value, onChange }) {
const [preview, setPreview] = useState();
const inputRef = useRef();
const handleChange = (e) => {
const nextValue = e.target.files[0];
onChange(name, nextValue);
};
const handleClearClick = () => {
const inputNode = inputRef.current;
if (!inputNode) return;
inputNode.value = '';
onChange(name, null);
};
useEffect(() => {
if (!value) return;
const nextPreview = URL.createObjectURL(value);
setPreview(nextPreview);
}, [value]);
return (
<div>
<img src={preview} alt="이미지 미리보기" />
<input type="file" accept="image/png, image/jpeg" onChange={handleChange} ref={inputRef} />
{value && <button onClick={handleClearClick}>X</button>}
</div>
);
}
export default FileInput;
useEffect(() => {
if (!value) return;
const nextPreview = URL.createObjectURL(value);
setPreview(nextPreview);
return () => {
setPreview();
URL.revokeObjectURL(nextPreview);
};
}, [value]);
프로그래밍에선 말 그대로 외부에 부수적인 작용을 하는 걸 의미한다.
let count = 0;
function add(a, b) {
const result = a + b;
count += 1; // 함수 외부의 값을 변경
return result;
}
const val1 = add(1, 2);
const val2 = add(-4, 5);
add 함수는 a, b를 파라미터로 받아 더한 값을 리턴한다. add 함수는 실행하면서 함수 외부의 상태(count 변수)가 바뀌기 때문에, 이런 함수를 "사이드 이펙트가 있다.'라고 한다. 우리에게 조금 더 친숙한 예로는 console.log 함수가 있다. console.log 함수를 사용하면 값을 계산해서 리턴하는 게 아니라 웹 브라우저 콘솔 창에 문자열을 출력한다. 외부 상태를 변경해서 문자열을 출력하는 것이다. 이렇게 함수 안에서 함수 바깥에 있는 값이나 상태를 변경하는 걸 '사이드 이펙트'라고 부른다.
useEffect는 리액트 컴포넌트 함수 안에서 사이드 이펙트를 실행하고 싶을 때 사용하는 함수이다. 예를들어 DOM 노드를 직접 변경한다거나, 브라우저에 데이터를 저장하고, 네트워크 request를 보내는 것들이다. 주로 리액트 외부에 있는 데이터나 상태를 변경할 때 사용한다.
useEffect(() => {
document.title = title; // 페이지 데이터를 변경
}, [title]);
useEffect(() => {
fetch('https://example.com/data') // 외부로 네트워크 리퀘스트
.then((response) => response.json())
.then((body) => setData(body));
}, [])
useEffect(() => {
localStorage.setItem('theme', theme); // 로컬 스토리지에 테마 정보를 저장
}, [theme]);
localStorage는 웹 브라우저에서 데이터를 저장할 수 있는 기능이다.
useEffect(() => {
const timerId = setInterval(() => {
setSecond((prevSecond) => prevSecond + 1);
}, 1000); // 1초마다 콜백 함수를 실행하는 타이머 시작
return () => {
clearInterval(timerId);
}
}, []);
setInterval이라는 함수를 쓰면 일정한 시간마다 콜백 함수를 실행할 수 있다.
앞에서 데이터 불러오는 기능을 만들 때 처음에는 onClick 이벤트 핸들러에서 네트워크 request를 보냈고, 페이지가 열리자마자 데이터를 불러오기 위해서 useEffect를 사용하는 걸로 바꿨다. 하지만 만약 둘 다 가능한 경우라면 어떻게 해야 할까?
딱히 정해진 규칙은 없다. 하지만 useEffect는 쉽게 말해서 '동기화'에 쓰면 유용한 경우가 많다. 여기서 동기화는 컴포넌트 안에 데이터와 리액트 바깥에 있는 데이터를 일치시기는 걸 의미한다. 아래 App 컴포넌트는 인풋 입력에 따라 페이지 제목을 바꾸는 컴포넌트이다.
import { useState } from 'react';
const INITIAL_TITLE = 'Untitled';
function App() {
const [title, setTitle] = useState(INITIAL_TITLE);
const handleChange = (e) => {
const nextTitle = e.target.value;
setTitle(nextTitle);
document.title = nextTitle;
};
const handleClearClick = () => {
const nextTitle = INITIAL_TITLE;
setTitle(nextTitle);
document.title = nextTitle;
};
return (
<div>
<input value={title} onChange={handleChange} />
<button onClick={handleClearClick}>초기화</button>
</div>
);
}
export default App;
handleChange 함수와 handleClearClick 함수는 모두 title 스테이트를 변경한 후에 document.title도 함께 변경해주고 있다. 여기서 document.title 값을 바꾸는 건 외부의 상태를 변경하는 거니까 사이드 이펙트이다. 만약 새로 함수를 만들어서 setTitle을 사용하는 코드를 추가할 때마다 document.title 값도 변경해야 한다는 걸 기억해뒀다가 관련된 코드를 작성해야 한다는 점은 아쉬울 수 있다.
import { useEffect, useState } from 'react';
const INITIAL_TITLE = 'Untitled';
function App() {
const [title, setTitle] = useState(INITIAL_TITLE);
const handleChange = (e) => {
const nextTitle = e.target.value;
setTitle(nextTitle);
};
const handleClearClick = () => {
setTitle(INITIAL_TITLE);
};
useEffect(() => {
document.title = title;
}, [title]);
return (
<div>
<input value={title} onChange={handleChange} />
<button onClick={handleClearClick}>초기화</button>
</div>
);
}
export default App;
useEffect를 사용한 예시에서는 document를 다루는 사이드 이펙트 부분만 따로 처리하고 있다. 이렇게 하니 setTitle 함수를 쓸 때마다 document.title을 변경하는 코드를 신경 쓰지 않아도 되니까 편리하다.
게다가 처음 렌더링 되었을 때 'Untitled'라고 페이지 제목을 변경하는 효과를 낼 수 있다. 그리고 이 코드를 본 사람이라면 누구든 이 컴포넌트는 title 스테이트 값을 가지고 항상 document.title에 반영해줄 것이라고 쉽게 예측할 수 있다. 이렇게 useEffect는 리액트 안과 밖의 데이터를 일치시키는데 활용하면 좋다. useEffect를 사용했을 때 반복되는 코드를 줄이고, 동작을 쉽게 예측할 수 있는 코드를 작성할 수 있기 때문이다.
useEffect(() => {
// 사이드 이펙트
return () => {
// 사이드 이펙트에 대한 정리
}
}, [dep1, dep2, dep3, ...]);
useEffect의 콜백 함수에서 사이드 이펙트를 만들면 정리가 필요한 경우가 있다. 이럴 때 콜백 함수에서 리턴 값으로 정리하는 함수를 리턴할 수 있다. 리턴한 정리 함수에서는 사이트 이펙트에 대한 뒷정리를 한다. 예를 들면 이미지 파일 미리보기를 구현할 때 Object URL을 만들어서 브라우저의 메모리를 할당(createObjectURL)했다. 정리 함수에서는 이때 할당한 메모리를 다시 해제(revokeObjectURL)해줬다.
쉽게 말해서 콜백을 한 번 실행했으면, 정리 함수도 반드시 한 번 실행된다고 생각하면 된다. 정확히는 새로운 콜백 함수가 호출되기 전에 실행되거나 (앞에서 실행한 콜백의 사이트 이펙트를 정리), 컴포넌트가 화면에서 사라지기 전에 실행된다. (맨 마지막으로 실행한 콜백의 사이트 이펙트를 정리)
import { useEffect, useState } from 'react';
function Timer() {
const [second, setSecond] = useState(0);
useEffect(() => {
const timerId = setInterval(() => {
console.log('타이머 실행중 ... ');
setSecond((prevSecond) => prevSecond + 1);
}, 1000);
console.log('타이머 시작 🏁');
return () => {
clearInterval(timerId);
console.log('타이머 멈춤 ✋');
};
}, []);
return <div>{second}</div>;
}
function App() {
const [show, setShow] = useState(false);
const handleShowClick = () => setShow(true);
const handleHideClick = () => setShow(false);
return (
<div>
{show && <Timer />}
<button onClick={handleShowClick}>보이기</button>
<button onClick={handleHideClick}>감추기</button>
</div>
);
}
export default App;
일정한 시간 간격마다 콜백 함수를 실행하는 setInterval이라는 하수도 정리가 필요한 사이드 이펙트이다. 이 컴포넌트는 렌더링이 끝나면 타이머를 시작하고, 화면에서 사라지면 타이머를 멈춘다. 사용자가 '보이기' 버튼을 눌렀을 때 show 값이 참으로 바뀌면 다시 렌더링 된다. Timer 컴포넌트에서는 useEffect에서 타이머를 시작하고, 정리 함수를 리턴한다. 다시 사용자가 '감추기' 버튼을 누르면 show 값이 거짓으로 바뀌면서 다시 렌더링 된다. 그럼 리액트에선 마지막으로 앞에서 기억해뒀던 정리 함수를 실행해 준다.
import './Rating.css';
const RATINGS = [1, 2, 3, 4, 5];
function Star({ selected = false, rating = 0, onSelect, onHover }) {
const className = `Rating-star ${selected ? 'selected' : ''}`;
const handleClick = onSelect ? () => onSelect(rating) : undefined;
const handleMouesOver = onHover ? () => onHover(rating) : undefined;
return (
<span
className={className}
onClick={handleClick}
onMouseOver={handleMouesOver}
>
★
</span>
);
}
function Rating({ className, value = 0, onSelect, onHover, onMouseOut }) {
return (
<div className={className} onMouseOut={onMouseOut}>
{RATINGS.map((rating) => (
<Star
key={rating}
selected={value >= rating}
rating={rating}
onSelect={onSelect}
onHover={onHover}
/>
))}
</div>
);
}
export default Rating;
// RatingInput.js
import { useState } from 'react';
import Rating from './Rating';
import './RatingInput.css';
function RatingInput({ name, value, onChange }) {
const [rating, setRating] = useState(value);
const handleSelect = (nextValue) => onChange(name, nextValue);
const handleMouseOut = () => setRating(value);
return (
<Rating
className="RatingInput"
value={rating}
onSelect={handleSelect}
onHover={setRating}
onMouseOut={handleMouseOut}
/>
);
}
export default RatingInput;
// Rating.js
import './Rating.css';
const RATINGS = [1, 2, 3, 4, 5];
function Star({ selected = false, rating = 0, onSelect, onHover }) {
const className = `Rating-star ${selected ? 'selected' : ''}`;
const handleClick = onSelect ? () => onSelect(rating) : undefined;
const handleMouesOver = onHover ? () => onHover(rating) : undefined;
return (
<span
className={className}
onClick={handleClick}
onMouseOver={handleMouesOver}
>
★
</span>
);
}
function Rating({ className, value = 0, onSelect, onHover, onMouseOut }) {
return (
<div className={className} onMouseOut={onMouseOut}>
{RATINGS.map((rating) => (
<Star
key={rating}
selected={value >= rating}
rating={rating}
onSelect={onSelect}
onHover={onHover}
/>
))}
</div>
);
}
export default Rating;