// api.js
const BASE_URL = 'https://learn.codeit.kr/api';
export async function getReviews({
order = 'createdAt',
offset = 0,
limit = 6,
}) {
const query = `order=${order}&offset=${offset}&limit=${limit}`;
const response = await fetch(`${BASE_URL}/film-reviews?${query}`);
if (!response.ok) {
throw new Error('리뷰를 불러오는데 실패했습니다');
}
const body = await response.json();
return body;
}
export async function createReview(formData) {
const response = await fetch(`${BASE_URL}/film-reviews`, {
method: 'POST',
body: formData,
});
if (!response.ok) {
throw new Error('리뷰를 생성하는데 실패했습니다.');
}
const body = await response.json();
return body;
}
// ReviewForm.js
const handleSubmit = async (e) => {
e.preventDefault();
const formData = new FormData();
formData.append('title', values.title);
formData.append('rating', values.rating);
formData.append('content', values.content);
formData.append('imgFile', values.imgFile);
try {
setSubmittingError(null);
setIsSubmitting(true);
await createReview(formData);
} catch (error) {
setSubmittingError(error);
return;
} finally {
setIsSubmitting(false);
}
setValues(INITIAL_VALUES);
};
// App.js
const handleSubmitSuccess = (review) => {
setItems((prevItems) => [review, ...prevItems]);
};
import { useState } from 'react';
import Rating from './Rating';
import ReviewForm from './ReviewForm';
import './ReviewList.css';
function formatDate(value) {
const date = new Date(value);
return `${date.getFullYear()}. ${date.getMonth() + 1}. ${date.getDate()}`;
}
function ReviewListItem({ item, onDelete, onEdit }) {
const handleDeleteClick = () => {
onDelete(item.id);
};
const handleEditClick = () => {
onEdit(item.id);
};
return (
<div className="ReviewListItem">
<img className="ReviewListItem-img" src={item.imgUrl} alt={item.title} />
<div>
<h1>{item.title}</h1>
<Rating value={item.rating} />
<p>{formatDate(item.createdAt)}</p>
<p>{item.content}</p>
<button onClick={handleEditClick}>수정</button>
<button onClick={handleDeleteClick}>삭제</button>
</div>
</div>
);
}
function ReviewList({ items, onDelete }) {
const [editingId, setEditingId] = useState(null);
const handleCancel = () => setEditingId(null);
return (
<ul>
{items.map((item) => {
if (item.id === editingId) {
const { imgUrl, title, rating, content } = item;
const initialValues = { title, rating, content, imgFile: null };
return (
<li key={item.id}>
<ReviewForm
initialValues={initialValues}
initialPreview={imgUrl}
onCancel={handleCancel}
/>
</li>
);
}
return (
<li key={item.id}>
<ReviewListItem
item={item}
onDelete={onDelete}
onEdit={setEditingId}
/>
</li>
);
})}
</ul>
);
}
export default ReviewList;
// ReviewForm.js
import { useState } from 'react';
import { createReview } from '../api';
import FileInput from './FileInput';
import RatingInput from './RatingInput';
import './ReviewForm.css';
const INITIAL_VALUES = {
title: '',
rating: 0,
content: '',
imgFile: null,
};
function ReviewForm({
initialValues = INITIAL_VALUES,
initialPreview,
onCancel,
onSubmit,
onSubmitSuccess,
}) {
const [values, setValues] = useState(initialValues);
const [isSubmitting, setIsSubmitting] = useState(false);
const [submittingError, setSubmittingError] = useState(null);
const handleChange = (name, value) => {
setValues((prevValues) => ({
...prevValues,
[name]: value,
}));
};
const handleInputChange = (e) => {
const { name, value } = e.target;
handleChange(name, value);
};
const handleSubmit = async (e) => {
e.preventDefault();
const formData = new FormData();
formData.append('title', values.title);
formData.append('rating', values.rating);
formData.append('content', values.content);
formData.append('imgFile', values.imgFile);
let result;
try {
setSubmittingError(null);
setIsSubmitting(true);
result = await onSubmit(formData);
} catch (error) {
setSubmittingError(error);
return;
} finally {
setIsSubmitting(false);
}
const { review } = result;
setValues(INITIAL_VALUES);
onSubmitSuccess(review);
};
return (
<form className="ReviewForm" onSubmit={handleSubmit}>
<FileInput
name="imgFile"
value={values.imgFile}
initialPreview={initialPreview}
onChange={handleChange}
/>
<input name="title" value={values.title} onChange={handleInputChange} />
<RatingInput
name="rating"
value={values.rating}
onChange={handleChange}
/>
<textarea
name="content"
value={values.content}
onChange={handleInputChange}
/>
{onCancel && <button onClick={onCancel}>취소</button>}
<button disabled={isSubmitting} type="submit">
확인
</button>
{submittingError && <div>{submittingError.message}</div>}
</form>
);
}
export default ReviewForm;
// App.js
import { useEffect, useState } from 'react';
import ReviewList from './ReviewList';
import ReviewForm from './ReviewForm';
import { createReview, getReviews, updateReview } from '../api';
const LIMIT = 6;
function App() {
const [order, setOrder] = useState('createdAt');
const [offset, setOffset] = useState(0);
const [hasNext, setHasNext] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [loadingError, setLoadingError] = useState(null);
const [items, setItems] = useState([]);
const sortedItems = items.sort((a, b) => b[order] - a[order]);
const handleNewestClick = () => setOrder('createdAt');
const handleBestClick = () => setOrder('rating');
const handleDelete = (id) => {
const nextItems = items.filter((item) => item.id !== id);
setItems(nextItems);
};
const handleLoad = async (options) => {
let result;
try {
setLoadingError(null);
setIsLoading(true);
result = await getReviews(options);
} catch (error) {
setLoadingError(error);
return;
} finally {
setIsLoading(false);
}
const { paging, reviews } = result;
if (options.offset === 0) {
setItems(reviews);
} else {
setItems((prevItems) => [...prevItems, ...reviews]);
}
setOffset(options.offset + options.limit);
setHasNext(paging.hasNext);
};
const handleLoadMore = async () => {
await handleLoad({ order, offset, limit: LIMIT });
};
const handleCreateSuccess = (review) => {
setItems((prevItems) => [review, ...prevItems]);
};
const handleUpdateSuccess = (review) => {
setItems((prevItems) => {
const splitIdx = prevItems.findIndex((item) => item.id === review.id);
return [
...prevItems.slice(0, splitIdx),
review,
...prevItems.slice(splitIdx + 1),
];
});
};
useEffect(() => {
handleLoad({ order, offset: 0, limit: LIMIT });
}, [order]);
return (
<div>
<div>
<button onClick={handleNewestClick}>최신순</button>
<button onClick={handleBestClick}>베스트순</button>
</div>
<ReviewForm
onSubmit={createReview}
onSubmitSuccess={handleCreateSuccess}
/>
<ReviewList
items={sortedItems}
onItemDelete={handleDelete}
onUpdate={updateReview}
onUpdateSuccess={handleUpdateSuccess}
/>
{hasNext && (
<button disabled={isLoading} onClick={handleLoadMore}>
더 보기
</button>
)}
{loadingError?.message && <span>{loadingError.message}</span>}
</div>
);
}
export default App;
// api.js
const BASE_URL = 'https://learn.codeit.kr/api';
export async function getReviews({
order = 'createdAt',
offset = 0,
limit = 6,
}) {
const query = `order=${order}&offset=${offset}&limit=${limit}`;
const response = await fetch(`${BASE_URL}/film-reviews?${query}`);
if (!response.ok) {
throw new Error('리뷰를 불러오는데 실패했습니다');
}
const body = await response.json();
return body;
}
export async function createReview(formData) {
const response = await fetch(`${BASE_URL}/film-reviews`, {
method: 'POST',
body: formData,
});
if (!response.ok) {
throw new Error('리뷰를 생성하는데 실패했습니다.');
}
const body = await response.json();
return body;
}
export async function updateReview(id, formData) {
const response = await fetch(`${BASE_URL}/film-reviews/${id}`, {
method: 'PUT',
body: formData,
});
if (!response.ok) {
throw new Error('리뷰를 수정하는데 실패했습니다.');
}
const body = await response.json();
return body;
}
// api.js
export async function deleteReview(id) {
const response = await fetch(`${BASE_URL}/film-reviews/${id}`, {
method: 'DELETE',
});
if (!response.ok) {
throw new Error('리뷰를 삭제하는데 실패했습니다.');
}
const body = await response.json();
return body;
}
// App.js
const handleDelete = async (id) => {
const result = await deleteReview(id);
if (!result) return;
setItems((prevItems) => prevItems.filter((item) => item.id !== id));
};
프로그래밍에서 Hook이란, 작성한 코드를 다른 프로그램에 연결해서 그 값이나 기능을 사용하는 것을 의미한다.
import { useState } from 'react';
function useAsync(asyncFunction) {
const [pending, setPending] = useState(false);
const [error, setError] = useState(null);
const wrappedFunction = async (...args) => {
setPending(true);
setError(null);
try {
return await asyncFunction(...args);
} catch (error) {
setError(error);
} finally {
setPending(false);
}
};
return [pending, error, wrappedFunction];
}
export default useAsync;
const handleLoad = useCallback(
async (options) => {
const result = await getReviewsAsync(options);
if (!result) return;
const { paging, reviews } = result;
if (options.offset === 0) {
setItems(reviews);
} else {
setItems((prevItems) => [...prevItems, ...reviews]);
}
setOffset(options.offset + options.limit);
setHasNext(paging.hasNext);
},
[getReviewsAsync]
);
아래 코드는 num 버튼을 누르면 num 스테이트 값이 증가되고, count 버튼을 누르면 count 스테이트 값을 증가시키는 컴포넌트이다. 이때 count 스테이트 값을 증가시키면서 콘솔에는 num 스테이트 값을 출력한다. useEffect Hook에서는 1초마다 addCount 함수를 실행하는 타이머를 실행한다.
import { useEffect, useState } from 'react';
function App() {
const [count, setCount] = useState(0);
const [num, setNum] = useState(0);
const addCount = () => {
setCount(c => c + 1);
console.log(`num: ${num}`);
}
const addNum = () => setNum(n => n + 1);
useEffect(() => {
console.log('timer start');
const timerId = setInterval(() => {
addCount();
}, 1000);
return () => {
clearInterval(timerId);
console.log('timer end');
};
}, []);
return (
<div>
<button onClick={addCount}>count: {count}</button>
<button onClick={addNum}>num: {num}</button>
</div>
);
}
export default App;
1초마다 count 값이 증가하는데, 버튼을 클릭해서 num 스테이트의 값이 바뀌더라도 콘솔 출력에서는 숫자가 바뀌지 않고 0만 계속 출력된다는 문제가 있다. 그 이유는 useEffect 안에서 addCount라는 함수를 사용하는데, 이 함수에서는 num 스테이트 값을 잘못 참조하기 때문이다. 과거의 num 스테이트 값을 계속해서 참조하고 있기 때문이다. 이런 문제점을 경고해주는 규칙이 react-hooks/exhaustive-deps라는 규칙이다. 리액트에서는 Prop이나 State와 관련된 값을 되도록이면 빠짐없이 디펜던시에 추가해서 항상 최신 값으로 useEffect나 useCallback을 사용하도록 권장하고 있다.
import { useEffect, useState } from "react";
function App() {
const [count, setCount] = useState(0);
const [num, setNum] = useState(0);
const addCount = () => {
setCount((c) => c + 1);
console.log(`num: ${num}`);
};
const addNum = () => setNum((n) => n + 1);
useEffect(() => {
console.log('timer start');
const timerId = setInterval(() => {
addCount();
}, 1000);
return () => {
clearInterval(timerId);
console.log('timer end');
};
}, [addCount]);
return (
<div>
<button onClick={addCount}>count: {count}</button>
<button onClick={addNum}>num: {num}</button>
</div>
);
}
export default App;
이렇게 작성하면 한 가지 문제가 있다. 실행해서 콘솔을 확인해보면 count가 바뀔 때마다 타이머를 새로 시작하고 종료하는 걸 반복한다는 걸 알 수 있다. addCount라는 함수는 렌더링 할 때마다 새로 만들어지는데, 이걸 디펜던시 리스트에 추가했기 때문에 useEffect의 콜백이 매번 불필요하게 실행되는 버그가 있는 것이다.
import { useCallback, useEffect, useState } from "react";
function App() {
const [count, setCount] = useState(0);
const [num, setNum] = useState(0);
const addCount = useCallback(() => {
setCount((c) => c + 1);
console.log(`num: ${num}`);
}, [num]);
const addNum = () => setNum((n) => n + 1);
useEffect(() => {
console.log('timer start');
const timerId = setInterval(() => {
addCount();
}, 1000);
return () => {
clearInterval(timerId);
console.log('timer end');
};
}, [addCount]);
return (
<div>
<button onClick={addCount}>count: {count}</button>
<button onClick={addNum}>num: {num}</button>
</div>
);
}
export default App;
앞에서처럼 디펜던시 리스트에 추가한 함수가 매번 바뀌는 문제를 해결하려면 함수를 useCallback으로 감싸주면 된다. useCallback을 사용하면 함수를 매번 생성하는 게 아니라 리액트에다 함수를 기억해둘 수 있다. 이때 리액트는 useCallback의 디펜던시 리스트 값이 바뀔 때만 함수를 새로 만들어준다. addCount 함수에서는 num이라는 스테이트를 참조하고 있으니까 이 값을 디펜던시 리스트에 추가했다. 이렇게하면 리액트는 num 값이 바뀔 때만 addCount 함수를 새로 만들 것이다.
이런 식으로 컴포넌트 안에서 만든 함수를 디펜던시 리스트에 사용할 때는 useCallback 훅으로 매번 함수를 새로 생성하는 걸 막을 수 있다.
사실 addCount라는 함수에서 num 값을 꼭 직접 참조할 필요는 없다. 그래서 useCallback을 쓰지 않고, 아래처럼 파라미터로 받아오게 할 수 있다. 이렇게 하면 addCount 함수 자체만 놓고 보면 바깥에 있는 스테이트 값을 직접적으로 참조하지 않기 때문에 오래된 스테이트 값을 참조할 염려가 없다.
import { useEffect, useState } from "react";
function App() {
const [count, setCount] = useState(0);
const [num, setNum] = useState(0);
const addCount = (log) => {
setCount((c) => c + 1);
console.log(log);
}
const addNum = () => setNum((n) => n + 1);
useEffect(() => {
console.log('timer start');
const timerId = setInterval(() => {
addCount(`num ${num}`);
}, 1000);
return () => {
clearInterval(timerId);
console.log('timer end');
};
}, [num]);
return (
<div>
<button onClick={addCount}>count: {count}</button>
<button onClick={addNum}>num: {num}</button>
</div>
);
}
export default App;
그리고 디펜던시 리스트를 이렇게 바꾸면 num 값이 바뀔 때마다 타이머를 재시작한다는 게 좀 더 명확해졌다. Prop이나 State 값을 사용할 때는 이렇게 되도록이면 파라미터로 넘겨서 사용하면, 어떻게 사용되는지 코드에서 명확하게 보여줄 수 있다.
const [state, setState] = useState(initialState);
초깃값을 계산하는 코드가 복잡한 경우에 활용한다.
const [state, setState] = useState(() => {
// ...
return initialState;
});
setState(nextState);
비동기 함수에서 최신 State 값을 가져와서 새로운 State 값을 만들 때 사용한다.
setState((prevState) => {
// ...
return nextState
});
컴포넌트 함수에서 사이드 이펙트(리액트 외부의 값이나 상태를 변경할 때)에 활용하는 함수
useEffect(() => {
// ...
}, []);
참고로 처음 렌더링 후에도 한 번 실행된다.
useEffect(() => {
// ...
}, [dep1, dep2, dep3, ...]);
useEffect(() => {
// 사이드 이펙트
return () => {
// 정리
}
}, [dep1, dep2, dep3, ...]);
const ref = useRef();
// ...
return <div ref={ref}>안녕 리액트!</div>;
const node = ref.current;
if (node) {
// node를 사용하는 코드
}
함수를 매번 새로 생성하는 것이 아니라 디펜던시 리스트가 변경될 때만 함수를 생성한다.
const handleLoad = useCallback((option) => {
// ...
}, [dep1, dep2, dep3, ...]);
자주 사용하는 Hook 코드들을 모아서 함수로 만들 수 있다. 이때 useOOO 처럼 반드시 맨 앞에 use라는 단어를 붙여서 다른 개발자들이 Hook이라는 걸 알 수 있게 해줘야 한다.
비동기 함수의 로딩, 에러 처리를 하는 데 사용할 수 있는 함수이다. 함수를 asyncFunction 이라는 파라미터로 추상화해서 wrappedFunction 이라는 함수를 만들어 사용하는 방식을 눈여겨보시면 좋을 것 같다.
function useAsync(asyncFunction) {
const [pending, setPending] = useState(false);
const [error, setError] = useState(null);
const wrappedFunction = useCallback(async (...args) => {
setPending(true);
setError(null);
try {
return await asyncFunction(...args);
} catch (error) {
setError(error);
} finally {
setPending(false);
}
}, [asyncFunction]);
return [pending, error, wrappedFunction];
}
toggle 함수를 호출할 때마다 value 값이 참/거짓으로 번갈아가면 바뀐다. ON/OFF 스위치 같은 걸 만들 때 유용하다.
function useToggle(initialValue = false) {
const [value, setValue] = useState(initialValue);
const toggle = () => setValue((prevValue) => !prevValue);
return [value, toggle];
}
start를 실행하면 callback이라는 파라미터로 넘겨준 함수를 timeout 밀리초 마다 실행하고, stop을 실행하면 멈춘다. setInterval이란 함수는 웹 브라우저에 함수를 등록해서 일정한 시간 간격마다 실행하는데, 실행할 때마다 사이드 이펙트를 만들고, 사용하지 않으면 정리를 해줘야 한다. clearInterval 이라는 함수를 실행해서 사이드 이펙트를 정리하는 부분을 눈여겨 보면 좋다.
function useTimer(callback, timeout) {
const [isRunning, setIsRunning] = useState(false);
const start = () => setIsRunning(true);
const stop = () => setIsRunning(false);
useEffect(() => {
if (!isRunning) return;
const timerId = setInterval(callback, timeout); // 사이드 이펙트 발생
return () => {
clearInterval(timerId); // 사이드 이펙트 정리
};
}, [isRunning, callback, timeout]);
return [start, stop];
}