리액트 훅은 함수형 컴포넌트 안에서만 사용할 수 있는 특별한 함수이다.
use-
로 시작한다. 직접 커스텀 훅을 만들 때도 마찬가지이다.useState()
, useEffect()
, useRef()
...useState()
함수를 호출하면 state를 관리하고 변경할 수 있는 기능이 함수형 컴포넌트에 추가된다.훅은 함수형 컴포넌트나 커스텀 훅에서만 사용할 수 있다.
훅은 반드시 컴포넌트의 루트에서만 사용할 수 있다. 즉 중첩 함수에서는 사용 불가.
조건문 안에서는 훅을 사용할 수 없다.
//컴포넌트 감싸서 불필요하게 다시 렌더링되지 않도록 react memo 사용
//리액트 메모 사용하여 부모컴포넌트가 변경되더라도, 오직 보고 있는 프로퍼티가 변경되었을 때만 이 컴포넌트 재렌더링되게 함
//따러서 ingredients와 ingredientForm에 전달하는 프로퍼티 값도 달라져야 재렌더링됨
const [ 현재상태, 상태변경함수 ] = useState();
"", number, boolean, [], {}
상관없이 원하는 모양을 가질 수 있다.오브젝트{}
로 굳이 합치지 말고 여러개로 분리하여 각각 state를 만들어 사용하는 것이 좋다.const IngredientForm = (props) => {
const [enteredTitle, setEnteredTitle] = useState("");
const [enteredAmount, setEnteredAmount] = useState("");
const titleChangeHandler = (event) => {
event.preventDefault();
setEnteredTitle(event.target.value);
};
const amountChangeHandler = (event) => {
event.preventDefault();
setEnteredAmount(event.target.value);
};
//...
컴포넌트가 렌더링 될 때마다 모든 데이터 목록을 가져와야 한다.
이 때 사용하는 훅이 useEffect()
이다.
클래스형 컴포넌트에서 componenetDidMount()
를 사용하는데, 함수형 컴포넌트에서는 useEffect()
훅을 사용하여 http get을 할 수 있다.
useState()
와 마찬가지로 함수형 컴포넌트나 다른 훅 안에서만 사용할 수 있고, 항상 루트에서만 사용해야 한다.
useEffect()
라는 이름은 이 훅이 부수 효과(side effect)를 관리하기 때문에 붙여졌다. HTTP 요청이 전형적인 부수 효과이다.
useEffect()
를 사용하면 된다.중요한 것은 useEffect()
는 모든 컴포넌트의 렌더링이 끝난 뒤 실행된다는 점이다.
useEffect()
의 함수는 컴포넌트가 렌더링 되고 나서, 컴포넌트가 리렌더링 될 때마다 실행된다.import React, { useEffect, useState } from "react";
import IngredientForm from "./IngredientForm";
import Search from "./Search";
import IngredientList from "./IngredientList";
const Ingredients = () => {
//여기서 Form에서 인풋 받아서 리스트로 출력함
//여기서 재료를 관리한다는건 useState()를 사용해야 한다는 뜻
const [userIngredients, setUserIngredients] = useState([]);
const addIngredientHandler = async (newIngredient) => {
//서버: firebase
const response = await fetch(
"파이어베이스/ingredients.json",
{
method: "POST",
body: JSON.stringify(newIngredient),
headers: {
"Content-Type": "application/json",
}, //자바스크립트 객체나 중첩 자바스크립트 객체로 변환- firebase는 "Content-Type" 헤더 필요
} //firebase가 자동으로 id 생성해주기 때문에 id 빼고 보내기
);
const resData = await response.json();
//서버에 업데이트 요청 완료!되면 로컬도 업데이트하기
setUserIngredients((prev) => [
...prev,
{
id: resData.name,
...newIngredient,
},
]);
};
const removeIngredientHandler = (ingredientId) => {
setUserIngredients((prevIngredients) =>
prevIngredients.filter((ingredient) => ingredient.id !== ingredientId)
);
};
//Ingredients 컴포넌트 렌더링 될 때 마다 모든 재료 목록 가져와야 함
//컴포넌트가 마운트 될 때 데이터 가져오기
//useEffect() 사용
useEffect(() => {
//Ingredients 컴포넌트가 렌더링된 이후 실행
// 그리고 Ingredients 컴포넌트가 렌더링 될 때 마다 실행되는 함수
const fetchData = async () => {
const response = await fetch(
"파이어베이스/ingredients.json"
);
const resData = await response.json();
const loadedIngredients = [];
for (const key in resData) {
//loadedIngredients는 상수이지만, push()sms loadedIngredients에 저장된 값을 변경하는게 아닌 메모리에 있는 배열을 변경하는 거기 때문에 사용 가능
//세로운 객체 빈 배열인 loadedIngredients에 넣기
loadedIngredients.push({
id: key,
title: resData[key].title,
amount: resData[key].amount,
});
}
setUserIngredients(loadedIngredients);
};
fetchData();
}, []);
return (
<div className="App">
<IngredientForm onAddIngredient={addIngredientHandler} />
<section>
<Search />
<IngredientList
ingredients={userIngredients}
onRemoveItem={removeIngredientHandler}
/>
</section>
</div>
);
};
export default Ingredients;
fectchData()를 useEffect() 외부, 즉 컴포넌트 함수에서 바로 실행하면 무한루프 발생한다.
- 컴포넌트 렌더링 > HTTP 요청 발생 > state 업데이트
=> 무한반복
useEffect()
도 하나의 컴포넌트 안에서 useState()
처럼 여러번 호출할 수 있다.useEffect(() => {
const fetchData = async () => {
const response = await fetch(
"파이어베이스/ingredients.json"
);
const resData = await response.json();
const loadedIngredients = [];
for (const key in resData) {
loadedIngredients.push({
id: key,
title: resData[key].title,
amount: resData[key].amount,
});
}
setUserIngredients(loadedIngredients);
};
fetchData();
}, []);
useEffect(() => {
console.log("재료 목록 렌더링: (종속 X, 렌더링 될 때마다 함수 실행)");
});
두 번 실행된다.
컴포넌트가 렌더링되면 첫 번째 useEffect()
가 실행되어 데이터를 가져온다. 이 때 두 번째 useEffect()도 실행되며 콘솔에 찍힌다. : 첫 번째 렌더링
가져온 값이 setUserIngredients()
로 state에 반영되면(이 작업은 시간이 걸려 바로 완료되지 않는다 물론 1초도 안걸리지만) 컴포넌트가 리렌더링된다. 그래서 다시 한번 렌더링이 진행되고 두 번째 useEffect()도 다시 실행되므로 콘솔에 한 번 더 찍히는 것이다. : 두 번째 렌더링
이처럼 useEffect()를 여러번 호출 할 수도 있고, 두 번째 인자의 배열을 통해 실행 빈도를 결정할 수 있다.
useEffect(() => {
const fetchData = async () => {
const response = await fetch(
"파이어베이스/ingredients.json"
//...
}
setUserIngredients(loadedIngredients);
};
fetchData();
}, []);
useEffect(() => {
console.log("재료 목록 렌더링: ", userIngredients);
}, [userIngredients]); //의존성에 userIngredients 추가
재료를 검색하여 검색어에 맞는 재료만 화면에 렌더링 해보자.
사용자가 입력한 값 state로 받기
useState()
OnClick
이벤트, event.target.value
사용자 입력할 때 마다 http 요청 보내기
useEffect()
import React, { useEffect, useState } from "react";
import Card from "../UI/Card";
import "./Search.css";
const Search = React.memo((props) => {
//props 구조분해 할당해서 사용
const { onLoadIngredients } = props;
const [enteredFilter, setEnteredFilter] = useState("");
const filterHandler = (event) => {
setEnteredFilter(event.target.value);
};
//http 요청 보내기
useEffect(() => {
//사용자가 뭔가 입력할 때 필터링한 데이터를 firebase에서 가져오기
//filterHandler 함수로 키 입력이 들어올 때 마다 http 요청 보내면 됨
//현재 키 입력이 들어올 때 마다 state 업데이트 하는데, 그 대신 useEffect 사용하여
//이펙트 함수 안에서 호출하여 인수로 넣는 함수에서 http 요청 보내기
const fetchData = async () => {
//파이어베이스 데이터 필터링
// enteredFilter에 입력된 값이 있으면, title이 enteredFilter와 같은 값 가져와라
const query =
enteredFilter.length === 0
? ""
: `?orderBy="title"&equalTo="${enteredFilter}"`; //오타수정
const response = await fetch(
`https://react-http-35c4a-default-rtdb.firebaseio.com/ingredients.json${query}`
);
const resData = await response.json();
const loadedIngredients = [];
for (const key in resData) {
//loadedIngredients는 상수이지만, push()는 loadedIngredients에 저장된 값을 변경하는게 아닌 메모리에 있는 배열을 변경하는 거기 때문에 사용 가능
//세로운 객체 빈 배열인 loadedIngredients에 넣기
loadedIngredients.push({
id: key,
title: resData[key].title,
amount: resData[key].amount,
});
}
// 데이터 가져오고 나서 검색결과만 보여줘야 하니까 Ingredients 컴포넌트 리스트 거기에 맞게 바꿔줘야함
onLoadIngredients(loadedIngredients);
};
fetchData();
}, [enteredFilter, onLoadIngredients]); //props.onLoadIngredients 구조분해할당해서 사용해야 의존성에 모든 props가 아닌 onLoadIngredients만 넣억서 원하는 바 대로 작동할 수 있음
return (
<section className="search">
<Card>
<div className="search-input">
<label>Filter by Title</label>
<input type="text" value={enteredFilter} onChange={filterHandler} />
</div>
</Card>
</section>
);
});
export default Search;
파이어베이스의 규칙을 수정한다.
⚠️ 문제 발생: 무한루프!
onLoadIngredients()
도 호출하게 된다.setUserIngredients()
가 호출되어 state가 변경된다.<Ingredients />
컴포넌트가 리렌더링된다.filteredIngredientsHandler()
객체 인스턴스가 생성되는데, 새로 생성된 인스턴스가 새로운 참조값으로 onLoadIngredients 프롭
에 전달되면, Search 컴포넌트의 useEffect에서 종속하는 onLoadIngredients
값이 달라졌다고 판단하므로 이펙트가 재실행된다.이를 막기 위해 useCallback()으로 해당 함수를 감싸면 된다.
import React, { useCallback, useEffect, useState } from "react";
import IngredientForm from "./IngredientForm";
import Search from "./Search";
import IngredientList from "./IngredientList";
const Ingredients = () => {
//여기서 Form에서 인풋 받아서 리스트로 출력함
//여기서 재료를 관리한다는건 useState()를 사용해야 한다는 뜻
const [userIngredients, setUserIngredients] = useState([]);
//Ingredients 컴포넌트 렌더링 될 때 마다 모든 재료 목록 가져와야 하는데 이미 Search에서 가져와서 목록에 넣어주고 있기 때문에 두번 중복으로 가져올 필요 없음
useEffect(() => {
console.log("재료 목록 렌더링: ", userIngredients);
}, [userIngredients]);
//무한루프 막기 위해 useCallback()을 사용하자.
const filteredIngredientsHandler = useCallback((filteredIngredients) => {
setUserIngredients(filteredIngredients);
}, []);
//이렇게 하면 이 함수는 다시 실행되지 않고 리액트는 이 함수를 캐싱(cache)하여 리렌더링되어도 남아있게 한다.
//따라서 Ingredients 컴포넌트가 리렌더링되어도 이 함수는 새로 생성되지 않아서 참조값이 바뀌지 않는다.
// 따라서 Search 컴포넌트의 onLoadIngredients에 넘겨준 함수는 이전에 렌더링할 때 사용한 함수의 참조값과 같으므로 이펙트 함수도 재실행되지 않는다.
const addIngredientHandler = async (newIngredient) => {
//서버: firebase
const response = await fetch(
"https://react-http-35c4a-default-rtdb.firebaseio.com/ingredients.json",
{
method: "POST",
body: JSON.stringify(newIngredient),
headers: {
"Content-Type": "application/json",
}, //자바스크립트 객체나 중첩 자바스크립트 객체로 변환- firebase는 "Content-Type" 헤더 필요
} //firebase가 자동으로 id 생성해주기 때문에 id 빼고 보내기
);
const resData = await response.json();
//서버에 업데이트 요청 완료!되면 로컬도 업데이트하기
setUserIngredients((prev) => [
...prev,
{
id: resData.name,
...newIngredient,
},
]);
};
const removeIngredientHandler = (ingredientId) => {
setUserIngredients((prevIngredients) =>
prevIngredients.filter((ingredient) => ingredient.id !== ingredientId)
);
};
return (
<div className="App">
<IngredientForm onAddIngredient={addIngredientHandler} />
<section>
<Search onLoadIngredients={filteredIngredientsHandler} />
<IngredientList
ingredients={userIngredients}
onRemoveItem={removeIngredientHandler}
/>
</section>
</div>
);
};
export default Ingredients;
Ingredients 컴포넌트에서 Search 컴포넌트의 이펙트함수와 동일한 데이터를 패치하는 이펙트함수를 없애면된다. 그러면 이렇게 중복이 발생하지 않는다.
그러면 이렇게 네트워크 탭을 보면 Ingredients.json 데이터를 한 번만 받아오는 것을 확인할 수 있다.
설정한 시간이 지났을 때 입력창 내용을 확인하여, 입력된 내용이 타이머가 시작된 시점에 입력된 내용과 같으면 사용자가 입력을 멈췄다는 뜻이되므로, 그 때만 요청 보내보자.
자바스크립트의 📚 클로저 때문에 enteredFilter
는 타이머가 설정된 시점에 값이 고정된다. 타이머가 시작할 때 사용자가 입력했던 값으로 고정되어 있기 때문에, 타이머가 0.5초 뒤에 만료되면 현재 사용자가 입력하는 값과 다를 수도 있다.
import React, { useEffect, useRef, useState } from "react";
import Card from "../UI/Card";
import "./Search.css";
const Search = React.memo((props) => {
const { onLoadIngredients } = props;
const [enteredFilter, setEnteredFilter] = useState("");
//✅ 현재 인풋에 입력된 값 가져오기 위해 useRef()사용
const inputRef = useRef();
const filterHandler = (event) => {
setEnteredFilter(event.target.value);
};
useEffect(() => {
//✅ setTimeout()으로 0.5초뒤 검색하기
setTimeout(() => {
//✅ enteredFilter와 inputRef.current.value가 같은 경우에만 http 요청 보내 검색하기
if (enteredFilter === inputRef.current.value) {
const fetchData = async () => {
const query =
enteredFilter.length === 0
? ""
: `?orderBy="title"&equalTo="${enteredFilter}"`;
const response = await fetch(
`https://react-http-35c4a-default-rtdb.firebaseio.com/ingredients.json${query}`
);
const resData = await response.json();
const loadedIngredients = [];
for (const key in resData) {
loadedIngredients.push({
id: key,
title: resData[key].title,
amount: resData[key].amount,
});
}
onLoadIngredients(loadedIngredients);
};
fetchData();
}
}, 500);
}, [enteredFilter, onLoadIngredients, inputRef]);
//✅ 의존성에 추가
return (
<section className="search">
<Card>
<div className="search-input">
<label>Filter by Title</label>
<input
ref={inputRef} //✅ ref 속성 추가하여 현재 값 알아내기
type="text"
value={enteredFilter}
onChange={filterHandler}
/>
</div>
</Card>
</section>
);
});
export default Search;
하지만 이 방법은 완벽한 방법이 아니다.
왜냐하면 이펙트가 실행될 때마다 새로운 타이머를 설정하고 있기 때문에, 이펙트는 입력이 변경될 때마다 계속 실행된다. 즉, 모두 따로 관리되는 아주 많은 타이머를 설정하고 있는 실정이다.
const timer = setTimeout(() => {
//...
}, 500);
clearTimeout()
을 호출하여 인수에 timer
를 넘기면 타이머를 해제할 수 있다.(참고)
종속성 배열을 비워둘 경우, 즉[]
(즉, 효과가 한 번만 실행됨)으로 설정한 경우, 컴포넌트가 마운트 해제될 때에만 클린업 펑션이 실행된다.
//http 요청 보내기
useEffect(() => {
const timer = setTimeout(() => {
if (enteredFilter === inputRef.current.value) {
//...
}
}, 500);
//클린업 펑션은 동일한 useEffect()가 실행되기 직전 실행된다.
//clearTimeout()에 timer를 인수로 보내어 지나간 타이머를 제거할 수 있다.
return () => {
clearTimeout(timer);
};
}, [enteredFilter, onLoadIngredients, inputRef]);
이렇게 하면 동일하게 동작하지만, 불필요한 지나간 타이머를 메모리에 유지하지 않기 때문에 메모리 효율은 더 좋아진다.
웹 서비스 구독하여 개발에 사용하는 경우,
어떤 값을 주기적으로 받아서 사용하는 경우에 이전에 받은 내용을 지우고 싶은 경우,
클린업 펑션을 사용하여 메모리를 효율적으로 사용하자.
현재 재료를 클릭하면 로컬에서는 삭제가 되고 있다.
// 재료 삭제
const removeIngredientHandler = (ingredientId) => {
//로컬에서 삭제
setUserIngredients((prevIngredients) =>
prevIngredients.filter((ingredient) => ingredient.id !== ingredientId)
)
};
하지만 새로고침을 하면 데이터 베이스에서 받아온 데이터가 다시 뜨기 때문에 데이터를 완전히 삭제하기 위해서는 데이터베이스의 데이터도 삭제해 줘야 한다.
fetch()
의 첫 번째 인수로는, 인수로 받은 재료의 아이디 값(ingredientId
)을 지정하여 쿼리를 설정한다.
fetch()
의 두 번째 인수로 { method: "DELETE" }
객체를 설정한다.
그러고 나서 로컬에서 삭제하는 기능을 수행한다.
삭제하는 http 요청이기 때문에 어떤 응답이 오는지는 중요하지 않고, 화면에 재료 목록이 없데이트되는 것이 중요하다.
// 재료 삭제
const removeIngredientHandler = (ingredientId) => {
// 서버에서 삭제하는 기능
fetch(
`파이어베이스/ingredients/${ingredientId}.json`,
// 노드 순서: ingredients/재료id
// 삭제할 노드 지정하여 삭제 요청 보내기
{
method: "DELETE",
}
).then((response) => {
// 삭제하는 거라서 어떤 응답오는지는 중요하지 않고 화면에 재료 목록 업데이트하는게 중요
// 로컬에서 삭제하는 기능
setUserIngredients((prevIngredients) =>
prevIngredients.filter((ingredient) => ingredient.id !== ingredientId)
);
});
};
데이터베이스에서 데이터를 가져오거나 추가하거나 삭제하는 등의 처리를 할 때, 딜레이가 발생한다. 이때 사용자 경험을 조금이라도 개선하기 위해서 로딩 인디케이터를 사용하여 데이터 로드 시 로딩 인디케이터가 화면에 표시되게 해보자.
useState()
를 사용하여 현재 로딩중인지에 대한 상태 값을 만든다.false
로 한다.//로딩 스피너 화면에 표시하기: 현재 로딩중인지에 대한 상태
const [isLoading, setIsLoading] = useState(false);
setIsLoading(true)
를 설정하여 로딩 스피너를 표시하고, 요청 응답 받은 후 setIsLoading(false)
를 설정하여 로딩 스피너가 화면에서 없어질 수 있도록 한다.const addIngredientHandler = async (newIngredient) => {
//🔥 로딩 스피너 사용
setIsLoading(true);
//서버에 http 요청
const response = await fetch(
"https://react-http-35c4a-default-rtdb.firebaseio.com/ingredients.json",
{
method: "POST",
body: JSON.stringify(newIngredient),
headers: {
"Content-Type": "application/json",
}, //자바스크립트 객체나 중첩 자바스크립트 객체로 변환- firebase는 "Content-Type" 헤더 필요
} //firebase가 자동으로 id 생성해주기 때문에 id 빼고 보내기
);
const resData = await response.json();
//🔥 로딩 스피너 중지
setIsLoading(false);
//서버에 업데이트 요청 완료 후 로컬 업데이트
setUserIngredients(
//...
);
};
로딩 스피너를 표시하고자 하는 컴포넌트로 loading={isLoading}
props을 보낸다.
로딩 스피너를 표시하고자 하는 컴포넌트의 부분에서 {props.loading && <LoadingIndicator />}
로 loading
이 true
인 경우 로딩 인디케이터 컴포넌트가 화면에 표시되게 설정한다.
파이어베이스는 http요청시 오류 발생이 많지 않다고는 하나 http 요청시 혹시나 오류가 발생할 수 있으므로, try catch 구문을 사용하여 에러 발생시 에러 모달을 띄워보자.
fetch는 Promise 반환하므로 catch()로 에러를 잡을 수 있다.
useState()
를 사용한다.//에러 핸들링
const [error, setError] = useState();
catch (error) {
setError("Something went wrong!"); // 에러 메시지 업데이트
setIsLoading(false); // 에러 발생 후, 로딩 스피너가 계속 돌아가지 않도록 멈추기
}
clearError
핸들러를 만들어 에러모달을 끌 수 있게한다.const clearError = () => {
setError(null); //모달창 닫기> null은 거짓으로 취급됨
};
return (
<div className="App">
{error && <ErrorModal onClose={clearError}>{error}</ErrorModal>}
//...
catch (error) {
setError("Something went wrong!");
setIsLoading(false);
//동일한 시점에 같은 함수 안에서 요청한 모든 상태 업데이트는 일괄 처리 된다(batch)
//setError로 렌더링 한번 되고 setIsLoading로 렌더링 한 번 더 되는 것이 아니라
//렌더링 한번 일어남
}
위의 catch()구문을 보면 setError()
, setIsLoading()
으로 state 업데이트가 연달아 업데이트 설정되고 있다.
setState()를 하면 컴포넌트가 리렌더링되는데, 그러면 위의 상황에서는 컴포넌트가 각각 한번 씩, 총 2번 리렌더링 될까?
정답은 아니다.
동일한 시점에 같은 함수 안에서 요청한 모든 상태 업데이트는 일괄 처리 된다(batch).
따라서 두 state 업데이트가 모여서 함께 처리되므로 컴포넌트 리렌더링은 한 번 일어난다.
그것은 단순히 다음을 호출하는 것을 의미합니다:
setName('Max');
setAge(30);
동일한 동기 실행 주기에서(예: 동일한 함수에서)는 2개의 컴포넌트 재렌더링 주기를 트리거하지 않는다.
대신 컴포넌트는 한 번만 다시 렌더링되고 두 상태 업데이트는 모두 동시에 일괄처리된다.(batch)
직접적으로 관련되지는 않지만 때때로 오해하기도 하는 것은 새 상태 값을 사용할 수 있는 경우이다.
console.log(name); // prints name state, e.g. 'Manu'
setName('Max');
console.log(name); // ??? what gets printed? 'Max'?
setName('Max');
를 호출하여 상태를 업데이트한 후 바로 다음에 콘솔에서 name
에 엑세스하면 새로운 값(예: 'Max') 이 나올것 같지만 그렇지 않다.
새 상태 값은 다음 컴포넌트 렌더링 주기에서만 사용할 수 있다.(setName() 호출 시 스케줄됨)
지금 여기에서 관리하고 있는 상태 3가지(userIngredients
, isLoading
, error
)는 서로 연관되어 있다.
이 상태들은 모두 HTTP요청을 주고 받는 경우 설정되는 상태들이다.
현재 이 상태들을 따로 관리하고 있지만, catch()구문에서 처럼 setError()
, setIsLoading()
을 나란히 사용하여 동시에 상태를 업데이트하기도 한다. 리액트 일괄처리 매커니즘 덕분에 이렇게 사용해도 문제가 없다.
state가 다른 state에게 종속되어 있다면 상태관리는 좀 더 복잡해진다. 물론 지금은 그런 상황은 아니다.
하지만 이전 state를 기반으로 새로운 state를 업데이트해야 하는 경우, useState()
보다 좀 더 나은 방법이 있는데, 바로 useReducer()
를 사용하는 것이다.
useReducer()
는 상태를 업데이트할 때 어떤 식으로 상태를 변경할 건지 정의할 수 있게 해준다.
리듀서를 사용하는데, 리듀서는 여러 개의 입력을 받아 하나의 결과를 반환하는 함수이다.
리듀서는 보통 컴포넌트 바깥에서 정의한다. 컴포넌트 내부와 딱히 연관성이 없기 때문에 괜찮다.
컴포넌트 내부에서 props으로 사용하는 경우에는 컴포넌트 내부에 작성하기도 한다고 한다.
// ✅ 리듀서 함수 정의
const ingredientReducer = (currentIngredients, action) => {
switch (action.type) {
case "SET": // 설정 GET: 새로운 재료 만들어서 반환
return action.ingredients; // 액션의 ingredients 프로퍼티에 기존 state 대체하는 재료 배열 넣어 반환
case "ADD": // 추가 POST: 새로운 상태(배열) 스냅샷 반환
return [...currentIngredients, action.ingredient]; //현재 상태(배열)에 새로운 항목 추가한 후 새로운 배열 반환
case "DELETE": // 삭제 DELETE: 현재 값에 필터 적용하여 모든 재료 항목의 id와 액션의 id 비교하여 동일하지 않은 재료만 남긴 새로운 배열 반환
return currentIngredients.filter(
(ingredient) => ingredient.id !== action.id
);
default: // 디폴트 케이스는 없어야 하기 때문에 오류 발생시키자.
throw new Error("여기로 오지 마세요!");
}
};
useReducer()
를 호출한다.const Ingredients = () => {
//useReducer() 호출하여 초기화하기
//인수로 리듀서 함수 받음, 두번째 인수는 옵션이긴 한데, 디폴트 state 넣을 수 있다. 여기엔 빈배열 넣자. 이 값이 currentIngredients로 전달된다.
const [userIngredients, dispatch] = useReducer(ingredientReducer, []);
//useReducer()는 userIngredients와 dispatch 함수를 반환한다.
setState() 호출 시 컴포넌트가 리렌더링되는 것처럼, 리듀서가 새로운 상태를 반환할 때마다 리액트는 컴포넌트를 리렌더링한다.
const filteredIngredientsHandler = useCallback((filteredIngredients) => {
//setUserIngredients(filteredIngredients);
dispatch({
type: "SET",
ingredients: filteredIngredients,
});
//🔥 setState() 호출 시 컴포넌트가 리렌더링되는 것처럼, 리듀서가 새로운 상태를 반환할 때마다 리액트는 컴포넌트를 리렌더링한다.
}, []);
const addIngredientHandler = async (newIngredient) => {
//...
// setUserIngredients((prev) => [
// ...prev,
// {
// id: resData.name,
// ...newIngredient,
// },
// ]);
// 🔥 type이 ADD인 경우 리듀서에서 반환되는 값에 위의 값처럼 설정되어 있음
dispatch({
type: "ADD",
ingredient: {
id: resData.name,
...newIngredient,
},
});
};
const removeIngredientHandler = async (ingredientId) => {
//...
// 로컬에서 삭제하는 기능
// setUserIngredients((prevIngredients) =>
// prevIngredients.filter((ingredient) => ingredient.id !== ingredientId)
// );
dispatch({
type: "DELETE",
id: ingredientId,
});
//...
리듀서를 사용하면 모든 업데이트 로직이 리듀서에 모여 있기 때문에, 상태관리에 더 편하다.
또한 단지 액션만 디스패치하면 되므로 코드가 더 깔끔해진다.
리듀서를 사용하면 데이터가 어떻게 관리되고 있는지 훨씬 파악하기 쉬워진다.
따라서 state의 형태가 다소 복잡하고, 이전 state를 기반으로 상태를 업데이트해야 하는 경우라면, 리듀서 사용을 진지하게 고려해 보는 것이 좋다. state를 한 곳에서 더 명확한 방식으로 관리할 수 있기 때문이다.
isLoading 상태와 error 상태를 보면 http 요청 전송 시 사용되고 있는데 이 state들은 따로 관리되고 있긴하지만 연결된거나 마찬가지이다.
useState()를 사용하는 방식도 괜찮지만 useReducer()를 사용하여 함께 관리해보자.
http 요청과 관계 있으므로 이름을 httpReducer라고 해보자.
// http 요청에 대한 리듀서
const httpReducer = (currentHttpState, action) => {
switch (action.type) {
case "SEND": //http 요청 전송 직전에 동작할 내용
// 리듀서가 알아서 요청까지 보내는건 아니다. 그건 밑에 코드에서 ㅇㅇ..
//여기서는 요청 전송과 관련있고 UI에 영향주는 상태들에 대한 상태만 관리하면 된다.
//즉 이 state에 의해 로딩인디케이터나 에러창 표시할 건지 결정할 수 있도록 하면 된다.
return { loading: true, error: null };
case "RESPONSE": // http 요청 응답 도착 시 동작할 내용
return { ...currentHttpState, loading: false };
//일반적으로 프로퍼티에 원하는 값 넣기 전에 원래 있던 state 값을 가져온 후, 전개연산자 사용해 키-값 쌍을 꺼내고 꺼낸 값을 새로 만든 객체에 합쳐준다.
//그래야 기존 state에서 누락되는 값이 없다 ㅇㅇ!
// loading: false 로 기존 loading 프로퍼티를 덮어 씌워주는 거다.
//새로 만들어지는 객체는 새로운 state로 반환된다.
case "ERROR": // http 요청 오류 발생 시 동작할 내용
return { loading: false, error: action.errorMessage };
case "CLEAR": // 에러 모달 닫을 때 동작할 내용
return { ...currentHttpState, error: null };
default:
throw new Error("여기로 오지 마세요!");
}
};
const [httpState, dispatchHttp] = useReducer(httpReducer, {
//초기 값으로 객체 보내자.
loading: false,
error: null,
});
//setIsLoading(true); 으로 로딩 스피너 돌아갈 때는 http 요청 보내는 중
dispatchHttp({ type: "SEND" });
// setIsLoading(false); 으로 로딩 스피너 다 돌아가고 나서는 응답 받은 것
dispatchHttp({ type: "RESPONSE" });
//setError("Something went wrong!");
//setIsLoading(false); 인 경우 에러 출력해야 할 경우
dispatchHttp({ type: "ERROR", errorMessage: "Something went wrong" });
// setError(null); 로 모달창 닫으며 에러 없애야 할 경우
dispatchHttp({ type: "CLEAR" });
<IngredientForm
onAddIngredient={addIngredientHandler}
//loading={isLoading}
loading={httpState.loading}
/>
이제 코드가 훨씬 간결해 졌다. 데이터를 변경하는 곳이 명확하니 데이터의 흐름도 더 명확히 보인다.
import React, { useCallback, useEffect, useReducer } from "react";
import IngredientForm from "./IngredientForm";
import Search from "./Search";
import IngredientList from "./IngredientList";
import ErrorModal from "../UI/ErrorModal";
const ingredientReducer = (currentIngredients, action) => {
switch (action.type) {
case "SET":
return action.ingredients;
case "ADD":
return [...currentIngredients, action.ingredient];
case "DELETE":
return currentIngredients.filter(
(ingredient) => ingredient.id !== action.id
);
default: // 디폴트 케이스는 없어야 하기 때문에 오류 발생시키자.
throw new Error("여기로 오지 마세요!");
}
};
// http 요청에 대한 리듀서
const httpReducer = (currentHttpState, action) => {
switch (action.type) {
case "SEND":
return { loading: true, error: null };
case "RESPONSE":
return { ...currentHttpState, loading: false };
case "ERROR":
return { loading: false, error: action.errorMessage };
case "CLEAR":
return { ...currentHttpState, error: null };
//모달창 닫기> null은 거짓으로 취급됨
default:
throw new Error("여기로 오지 마세요!");
}
};
const Ingredients = () => {
const [userIngredients, dispatch] = useReducer(ingredientReducer, []);
const [httpState, dispatchHttp] = useReducer(httpReducer, {
loading: false,
error: null,
});
//Ingredients 컴포넌트 렌더링 될 때 마다 모든 재료 목록 가져와야 하는데 이미 Search에서 가져와서 목록에 넣어주고 있기 때문에 두번 중복으로 가져올 필요 없음
useEffect(() => {
console.log("재료 목록 렌더링: ", userIngredients);
}, [userIngredients]);
//컴포넌트 첫 렌더링시 자녀인 Search 컴포넌트를 렌더링할 때 onLoadIngredients()도 호출하게 된다.
//그러면 그 함수 안의 setUserIngredients()가 호출되어 state가 변경된다.
//따라서 Ingredients 컴포넌트가 리렌더링된다.
//그러면 또 다시 새로운 filteredIngredientsHandler() 객체 인스턴스가 생성되는데
//새로 생성된 인스턴스가 새로운 참조값으로 onLoadIngredients 프롭에 전달되면
//Search 컴포넌트의 useEffect에서 종속하는 onLoadIngredients 값이 달라졌다고 판단되므로 이펙트가 재실행된다.
//이렇게 무한 루프에 빠져버린다.
//이를 막기 위해 useCallback()을 사용하자.
const filteredIngredientsHandler = useCallback((filteredIngredients) => {
dispatch({
type: "SET",
ingredients: filteredIngredients,
});
}, []);
//이렇게 하면 이 함수는 다시 실행되지 않고 리액트는 이 함수를 캐싱(cache)하여 리렌더링되어도 남아있게 한다.
//따라서 Ingredients 컴포넌트가 리렌더링되어도 이 함수는 새로 생성되지 않아서 참조값이 바뀌지 않는다.
// 따라서 Search 컴포넌트의 onLoadIngredients에 넘겨준 함수는 이전에 렌더링할 때 사용한 함수의 참조값과 같으므로 이펙트 함수도 재실행되지 않는다.
const addIngredientHandler = async (newIngredient) => {
dispatchHttp({ type: "SEND" });
//서버 업데이트
const response = await fetch(
"https://react-http-35c4a-default-rtdb.firebaseio.com/ingredients.json",
{
method: "POST",
body: JSON.stringify(newIngredient),
headers: {
"Content-Type": "application/json",
}, //자바스크립트 객체나 중첩 자바스크립트 객체로 변환- firebase는 "Content-Type" 헤더 필요
} //firebase가 자동으로 id 생성해주기 때문에 id 빼고 보내기
);
const resData = await response.json();
dispatchHttp({ type: "RESPONSE" });
//서버에 업데이트 요청 완료되면 로컬도 업데이트
dispatch({
type: "ADD",
ingredient: {
id: resData.name,
...newIngredient,
},
});
};
// 재료 삭제
const removeIngredientHandler = async (ingredientId) => {
dispatchHttp({ type: "SEND" });
try {
// 서버에 삭제
await fetch(
`https://react-http-35c4a-default-rtdb.firebaseio.com/ingredients/${ingredientId}.json`,
// 노드 순서: ingredients/재료id
// 삭제할 노드 지정하여 삭제 요청 보내기
{
method: "DELETE",
}
);
dispatchHttp({ type: "RESPONSE" });
// 삭제하는 거라서 어떤 응답오는지는 중요하지 않고 화면에 재료 목록 업데이트하는게 중요
// 로컬에서 삭제
dispatch({
type: "DELETE",
id: ingredientId,
});
//fetch는 Promise 반환하므로 catch()로 에러 캐치
} catch (error) {
dispatchHttp({ type: "ERROR", errorMessage: "Something went wrong" });
}
};
const clearError = () => {
dispatchHttp({ type: "CLEAR" });
};
return (
<div className="App">
{httpState.error && (
<ErrorModal onClose={clearError}>{httpState.error}</ErrorModal>
)}
<IngredientForm
onAddIngredient={addIngredientHandler}
loading={httpState.loading}
/>
<section>
<Search onLoadIngredients={filteredIngredientsHandler} />
<IngredientList
ingredients={userIngredients}
onRemoveItem={removeIngredientHandler}
/>
</section>
</div>
);
};
export default Ingredients;
사용자가 로그인을 했을 때만 재료 항목을 보여줄 수 있도록 로그인 상태를 관리해보자.
auth 상태의 경우 많은 컴포넌트에 전반적으로 쓰여질 수 밖에 없다. 따라서 불필요한 prop drilling이 일어나게 되는데 이를 보다 잘 관리하기 위해 useContext()를 사용하여 상태관리를 할 수도 있다.
연습을 위해 레쯔고-!
📍 /src/context/auth-context.js
import { createContext } from "react";
//✅ 컨텍스트 갹체 ✅ AuthContext 생성하여 기본 값 설정
export const AuthContext = createContext({
isAuth: false, // 로그인 상태
login: () => {}, // 로그인 핸들러
});
const [isAuthenticated, setIsAuthenticated] = useState(false);
.Provider
를 붙여 리액트 컴포넌트를 반환할 수 있게 한다.<AuthContext.Provider />
는 value
값을 받는데, 초기 값으로는 앞서 생성한 AuthContext
컨텍스트 객체의 기본 값을 받는다.{ isAuth: isAuthenticated, login: loginHandler }
객체를 넣어서 isAuth, login 프로퍼티에 AuthContextProvider 컴포넌트 내부에서 생성한 isAuthenticated 상태와 로그인 핸들러를 할당하여 설정하면 된다.import { createContext, useState } from "react";
// createContext() 로 컨텍스트 객체 생성
//✅ 컴포넌트 ✅ 생성하여 리액트 컴포넌트 반환
const AuthContextProvider = (props) => {
//사용자 로그인 상태 관리
const [isAuthenticated, setIsAuthenticated] = useState(false);
//로그인 핸들러
const loginHandler = () => {
setIsAuthenticated(true);
};
//위에서 설정한 컨텍스트에 .Provider 붙이면 리액트 컴포넌트 얻을 수 있음
//AuthContext.Provider는 value 값을 받는데 위에서 컨텍스트 생성하여 기본 값으로 설정한 객체 모양을 받는다.
return (
<AuthContext.Provider
value={{ isAuth: isAuthenticated, login: loginHandler }}
>
{props.children}
</AuthContext.Provider>
);
};
export default AuthContextProvider;
📍 /src/index.js
에 <AuthContextProvider />
프로바이더 컴포넌트를 가져와 렌더 컴포넌트 전체를 감싸주면 하위 컴포넌트에서 컨텍스트를 사용할 수 있다.
import React from "react";
import ReactDOM from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
import "./index.css";
import App from "./App";
import AuthContextProvider from "./context/auth-context";
const root = ReactDOM.createRoot(document.getElementById("root"));
root.render(
<AuthContextProvider>
<BrowserRouter>
<App />
</BrowserRouter>
</AuthContextProvider>
);
📍 /src/App.js
에는 프로바이더 컴포넌트가 아닌 AuthContext 객체 자체를 가져온다.
함수형 컴포넌트에서 컨텍스트를 사용하려면 useContext()
훅을 사용하면 된다.
1. useContext()
에 AuthContext
객체를 인수로 보내 변수를 생성하여 사용하면 된다.
2. authContext.isAuth
를 구독(확인)하면, App은 컨텍스트 값이 변경될 때마다 재구성된ㄴ다.
import React, { useContext } from "react";
import Ingredients from "./components/Ingredients/Ingredients";
import Auth from "./components/Auth";
import { AuthContext } from "./context/auth-context";
//로그인 한 경우에만 재료 목록 반환하기
const App = (props) => {
const authContext = useContext(AuthContext);
let content = <Auth />;
if (authContext.isAuth) {
content = <Ingredients />;
}
return content;
};
export default App;
Auth 컴포넌트에서 버튼 클릭시 로그인 핸들러가 작동되게 하기 위해서 AuthContext가 필요하다.
App 컴포넌트에서 그랬던것 처럼 컨텍스트를 사용하기 위해 여기서도 useContext()
를 호출하여 인수로 AuthContext
를 보내고 변수에 할당하여 사용하면 된다.
로그인 핸들러를 사용하고 싶기 때문에 아래 처럼 사용하면 된다.
import React, { useContext } from "react";
import Card from "./UI/Card";
import "./Auth.css";
import { AuthContext } from "../context/auth-context";
const Auth = (props) => {
const authContext = useContext(AuthContext);
const loginHandler = () => {
authContext.login();
};
return (
<div className="auth">
<Card>
<h2>You are not authenticated!</h2>
<p>Please log in to continue.</p>
<button onClick={loginHandler}>Log In</button>
</Card>
</div>
);
};
export default Auth;
현재 <Ingredients />
함수형 컴포넌트 안에는 addIngredientHandler
핸들러 함수가 정의되어 있는데 이 핸들러 함수를 하위 컴포넌트인 IngredientForm
으로 props으로 전달하고 있다.
이때 어떤 문제가 발생하느냐..!
언제든지 함수형 컴포넌트가 재구성되어 컴포넌트 함수 전체가 다시 실행되면, 이 핸들러 함수도 다시 생성된다. 그렇다..! 완전히 새로운 함수로 재생성 되어 버리는 것이다!
그러면 하위 컴포넌트로 props으로 이 새롭게 생성된 핸들러 함수가 전달되기 때문에 참조값이 바껴버린다. 그러면 하위 컴포넌트는 값이 실제로 바뀌지 않았더라도 어라! 값이 바뀌었네! 재생성해야지~ 이렇게 되어버리는 것이다.
(참고) useReducer 의 경우엔 다시 호출되더라도 재생성되지 않는다.
리액트는 리듀서가 이 컴포넌트에서 이미 초기화 됐다는 것을 감지하면 초기화된 값을 사용한다.
<IngredientForm>
컴포넌트에 콘솔 넣어서 실행해보자.
<IngredientForm>
컴포넌트가 재렌더링된다.addIngredientHandler
핸들러 함수를 props으로 받고 있는 하위컴포넌트인 <IngredientForm>
컴포넌트를 React.memo()로 감싸서 사용중인데도 그렇다.
아래쪽에 데이터를 표시하는데 위에 있는 다른 컴포넌트인 Form 컴포넌트를 재렌더링 할 필요는 없다.
loading 상태
useCallback()으로 add 핸들러를 감싸주면 된다.
그러면 React.memo가 부모 컴포넌트가 재구성될 때 새로 받은 함수가 기존 함수와 같다는 것을 감지하여 자식 컴포넌트를 재구성하지 않는다.
물론 자식 컴포넌트에 있는 loading 상태가 변경될 때는 React.memo를 무시하고 자식 컴포넌트인 폼 컴포넌트가 재구성된다.
이렇게 하면 React.memo()가 제역할을 한다.
삭제하는 핸들러도 마찬가지이다.
불필요한 렌더링이 한 번 더 일어나고 있다.
removeIngredientHandler 핸들러를 props으로 받는 하위 컴포넌트인 <IngredientList / >
컴포넌트의 의존성은 리무브 핸들러이다.
따라서 리무브 핸들러를 콜백으로 감싸야 한다.
그러고 나서 하위컴포넌트인 리스트 컴포넌트를 리액트 메모로 감싸면, 부모 컴포넌트가 재구성될 때 새로 받은 리무브핸들러 함수가 기존과 같다는 것을 감지하여 자식 컴포넌트인 리스트 컴포넌트를 재구성하지 않을 수 있다.
removeIngredientHandler 핸들러 useCallback으로 감싸고 props으로 핸들러 받는 하위 컴포넌트 React.memo()로 감싸면 불필요한 렌더링은 사라진다.
const Ingredients = () => {
//..
const removeIngredientHandler = useCallback(async (ingredientId) => {
//...
}, []);
return (
<div className="App">
//...
<IngredientList
ingredients={userIngredients}
onRemoveItem={removeIngredientHandler}
/>
</div>
);
};
import React from "react";
import "./IngredientList.css";
const IngredientList = React.memo((props) => {
console.log("IngredientList: 얘는 몇번이나 재렌더링 되나 보자");
return (
<section className="ingredient-list">
<h2>Loaded Ingredients</h2>
<ul>
{props.ingredients.map((ig) => (
<li
key={ig.id}
id={ig.id}
onClick={props.onRemoveItem.bind(this, ig.id)}
>
<span>{ig.title}</span>
<span>{ig.amount}x</span>
</li>
))}
</ul>
</section>
);
});
export default IngredientList;
함수
를 저장하는데, 이 함수는 변하지 않아서 함수가 새로 생성되지 않는다.값
을 저장한다.useMemo()
훅은 컴포넌트를 저장(memorizing)하는 또 다른 방식이다.
removeIngredientHandler 핸들러는 useCallback으로 감싸고, IngredientList 컴포넌트는 react.memo로 감싸지 않는다. 대신 컴포넌트를 useMemo()훅에서 반환하여 사용한다.
useMemo()를 호출하고 함수를 인수로 넘기고, 반환값에 IngredientList 컴포넌트를 넣어주자.
인수로 넘겨지는 함수는 우리가 저장하는 값이 아니라 리액트가 나중에 실행할 함수이다.
이 함수가 반환하는 값이 우리가 저장할 값으로, 여기서는 IngredientList 컴포넌트를 반환하면 된다.
디펜던시로 userIngredients
, removeIngredientHandler
를 넣어준다.
두 가지가 바뀔경우 리액트는 ingredientList 함수를 실행하여 저장할 새로운 객체를 만든다.
그러고 나서 새로운 값으로 IngredientList 컴포넌트가 재구성되어 반환된다.
import React, { useCallback, useEffect, useMemo, useReducer } from "react";
const Ingredients = () => {
//..
//useMemo()를 호출하고 함수를 인수로 넘긴다.
const ingredientList = useMemo(() => {
return (
<IngredientList
ingredients={userIngredients}
onRemoveItem={removeIngredientHandler}
/>
);
}, [userIngredients, removeIngredientHandler]);
// userIngredients, removeIngredientHandler 가 바뀔경우 리액트는 ingredientList 함수를 실행하여 저장할 새로운 객체를 만든다.
// 그러고 나서 새로운 값으로 IngredientList 컴포넌트가 재구성되어 반환된다.
return (
<div className="App">
{httpState.error && (
<ErrorModal onClose={clearError}>{httpState.error}</ErrorModal>
)}
<IngredientForm
onAddIngredient={addIngredientHandler}
loading={httpState.loading}
/>
<section>
<Search onLoadIngredients={filteredIngredientsHandler} />
{ingredientList} //🔥
</section>
</div>
);
};
컴포넌트를 저장할 때 보통은 useMemo() 보다는 React.memo()를 사용하지만, useMemo()를 사용하면 어떤 데이터든 저장하여 컴포넌트가 렌더링 될 때마다 다시 생성되지 않도록 할 수 있다.
복잡한 값에 대한 연산 수행 시 계산하는데 시간이 너무 오래 걸린다면, 해당 값에 useMemo() 사용을 고려해 볼 수 있다. 그러면 컴포넌트가 렌더링 될 때 마다 다시 계산되지 않고, 정말 필요한 경우에만 다시 계산된다.
이렇게 최적화 해도 되지만, 최적화 하지 않는 것도 답이다.
왜냐하면 리렌더링은 아주 강력한 기능이기 때문이다.
작은 컴포넌트의 경우 간단한 업데이트는 순식간에 리렌더링 된다.
useCallback
, react.memo
, useMemo
로 줄일 수 있지만 말이다.아주 간단한 컴포넌트라면 React.memo를 추가하지 않는 편이 나을 수도 있다.
리액트가 항상 props의 변경사항을 확인해야 하기 때문에, 아주 작은 컴포넌트의 경우 변경사항을 확인하는 것 보다 리렌더링하는게 성능상 더 빠를 수도 있다.
모든 것을 최적화할 필요는 없다 😇
훅을 만들 때 가장 중요한 점은, 이름을 반드시 use-
로 시작해야 한다는 점이다.
훅은 일반 자바스크립트 함수이지만 리액트가 특별히 다룰 뿐이다.
커스텀 훅에서는 state와 관련된 모든 훅을 사용할 수 있다.
또한 커스텀훅을 사용하는 컴포넌트는 커스텀 훅에 작성된 코드를 본인 컴포넌트 안에 있는 것처럼 커스텀 훅을 실행 할 수 있다.
훅의 동작 원리:
그리고 커스텀 함수를 여러 컴포넌트가 공유하여 같은 코드에 같은 데이터 넣고 실행하는 것이 아닌, 각 함수형 컴포넌트가 커스텀 훅에 대한 각자의 스냅샷을 가진다. (아주 중요!)
각자 stateful(state관련) 로직이 있지만, 각 로직의 생김새는 커스텀 훅을 사용하는 컴포넌트 마다 다르다.
http 요청 보내는 작업을 여러번 수행하고, 패턴도 항상 비슷하므로 커스텀 훅을 만들어 사용해보자.
중요한 점은 일어나는 동작이 해당 로직을 사용하는 컴포넌트의 state에 영향을 준다는 점이다. 따라서 일반 함수는 사용할 수 없다. 물론 요청 전송하는 일반함수를 만들어 요청 전송 로직을 구현할 수 있지만, 일반 하수에서는 이벤트를 디스패치하고 해당 함수를 호출한 컴포넌트의 state를 변경할 수는 없다.
그건 훅만 할 수 있다.
📍 /src/hooks/htpp.js