[React Hooks] 리액트 훅

summereuna🐥·2023년 8월 3일
0

React JS

목록 보기
68/69

리액트 훅이란?


리액트 훅은 함수형 컴포넌트 안에서만 사용할 수 있는 특별한 함수이다.

  • 훅 함수 이름은 모두 use-로 시작한다. 직접 커스텀 훅을 만들 때도 마찬가지이다.
    • 예) useState(), useEffect(), useRef() ...
  • 리액트 훅을 함수형 컴포넌트에서 호출하면, 훅이 가진 능력이 해당 함수형 컴포넌트에 추가된다.
    • 예) 함수형 컴포넌트 안에서 useState() 함수를 호출하면 state를 관리하고 변경할 수 있는 기능이 함수형 컴포넌트에 추가된다.

훅 규칙


  1. 훅은 함수형 컴포넌트나 커스텀 훅에서만 사용할 수 있다.

  2. 훅은 반드시 컴포넌트의 루트에서만 사용할 수 있다. 즉 중첩 함수에서는 사용 불가.

  3. 조건문 안에서는 훅을 사용할 수 없다.

//컴포넌트 감싸서 불필요하게 다시 렌더링되지 않도록 react memo 사용
//리액트 메모 사용하여 부모컴포넌트가 변경되더라도, 오직 보고 있는 프로퍼티가 변경되었을 때만 이 컴포넌트 재렌더링되게 함
//따러서 ingredients와 ingredientForm에 전달하는 프로퍼티 값도 달라져야 재렌더링됨

✅ useState()


const [ 현재상태, 상태변경함수 ] = useState();
  • state는 "", number, boolean, [], {} 상관없이 원하는 모양을 가질 수 있다.
  • 관리해야할 상태가 여러 개라면, state를 오브젝트{}로 굳이 합치지 말고 여러개로 분리하여 각각 state를 만들어 사용하는 것이 좋다.
    • 객체나 배열 형태의 state는 여러 데이터가 동시에 변경되거나, 여러 데이터를 동시에 변경하고 싶은 경우에만 사용하는 것이 좋다.
      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()


⚙️ 데이터 페치하기

컴포넌트가 렌더링 될 때마다 모든 데이터 목록을 가져와야 한다.
이 때 사용하는 훅이 useEffect()이다.

  • 클래스형 컴포넌트에서 componenetDidMount()를 사용하는데, 함수형 컴포넌트에서는 useEffect() 훅을 사용하여 http get을 할 수 있다.

  • useState()와 마찬가지로 함수형 컴포넌트나 다른 훅 안에서만 사용할 수 있고, 항상 루트에서만 사용해야 한다.

  • useEffect()라는 이름은 이 훅이 부수 효과(side effect)를 관리하기 때문에 붙여졌다. HTTP 요청이 전형적인 부수 효과이다.

    • 부수 효과란 어떤 로직이 실행되어 응용 프로그램에 예상치 못한 영향을 주는 것을 의미한다. 예를 들어 여기에서 데이터를 가져오는 동작은 렌더링 진행 과정에서 끝나지 않고, JSX 코드 범위를 벗어난 곳까지 영향을 줄 수도 있다. 문서의 제목을 바꾼다거나 말이다.
      따라서 일반 컴포넌트로 처리할 수 없는 이런 렌더링 과정은 useEffect()를 사용하면 된다.
  • 중요한 것은 useEffect()모든 컴포넌트의 렌더링이 끝난 뒤 실행된다는 점이다.

    • useEffect가 적힌 루트 컴포넌트가 처음으로 ✅ 렌더링되고 나서 useEffect()에 전달한 함수, 즉 인자로 넘긴 함수가 실행된다.
      따라서 인자로 넘긴 이 함수는 컴포넌트가 ✅ 리렌더링 될 때마다 실행된다.
    • 기억하자! 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()의 종속성


  • 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 업데이트 되어 두 번째 렌더링 때는 userIngredients이 뜸

⚙️ 검색 기능 만들기


재료를 검색하여 검색어에 맞는 재료만 화면에 렌더링 해보자.

  1. 사용자가 입력한 값 state로 받기

    • useState()
    • OnClick 이벤트, event.target.value
  2. 사용자 입력할 때 마다 http 요청 보내기

    • useEffect()
    • 파이어 베이스 쿼리 설정으로 파이어 베이스 데이터 필터링하기
    • 데이터 가져온 후 검색결과만 리스트에 띄우기 위해 Ingredients 컴포넌트로 props 함수에 인자 넣어 올려보내기
    • 종속성 배열 설정: 사용자 입력 값이 바뀔 때, props 함수가 바뀔 때만 http 요청 보내게 설정하기

파이어 베이스 데이터 필터링해서 가져오기

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;

파이어베이스 🌟rules 수정

파이어베이스의 규칙을 수정한다.



⚠️ 문제 발생: 무한루프!

  • 컴포넌트 첫 렌더링시 자녀인 Search 컴포넌트를 렌더링할 때, onLoadIngredients()도 호출하게 된다.
  • 그러면 그 함수 안의 setUserIngredients()가 호출되어 state가 변경된다.
  • 따라서 <Ingredients /> 컴포넌트가 리렌더링된다.
  • 그러면 또 다시 새로운 filteredIngredientsHandler() 객체 인스턴스가 생성되는데, 새로 생성된 인스턴스가 새로운 참조값으로 onLoadIngredients 프롭에 전달되면, Search 컴포넌트의 useEffect에서 종속하는 onLoadIngredients 값이 달라졌다고 판단하므로 이펙트가 재실행된다.
  • 이렇게 무한 루프에 빠져버린다.

이를 막기 위해 useCallback()으로 해당 함수를 감싸면 된다.

✅ useCallback()


⚙️ 무한루프 발생 막기: 컴포넌트 재렌더링시 새로 생성되어 참조값 다시 생성되는 함수 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;

중복되는 데이터 페치 수정


1. 데이터 중복 문제 확인

  • 콘솔을 확인하니 데이터를 중복해서 가져오고 있는 것을 확인할 수 있다.
    1. useEffect()는 컴포넌트가 렌더링되고 나서 실행되는데, 첫 렌더링시에는 데이터가 아직 패치되지 않았다. 그래서 콘솔에 빈 목록이 뜬다.
    2. 데이터 패치 후 컴포넌트가 재렌더링되면 된다. 콘솔에 두 번째 라인을 보면 데이터가 잘 패치 된 것을 확인할 수 있다.
  • 따라서 2번 렌더링 되는건 원래 그런거다. 그런데 3번째 라인이 문제다. 굳이 필요없는 중복이 발생한 것이다.

2. 원인

  • 네트워크 탭을 확인해보면 Search.js와 Ingredients.js에서 데이터를 페치하고 있는 것을 확인할 수 있다.
    1. Ingredients 컴포넌트가 렌더링 되면서 자식 컴포넌트인 Search 컴포넌트도 렌더링된다. 이때 Search 컴포넌트에서 컴포넌트를 처음 렌더링 할때는 데이터가 아직 패치되지 않았다.
    2. Search 컴포넌트에서 데이터 패치후, 컴포넌트가 재렌더링된다.
    3. 그러고나서 Ingredients 컴포넌트도 데이터를 패치하여 재렌더링된다.
      이미 Search 컴포넌트의 이펙트 함수에서 데이터를 패치 했기 때문에 Ingredients 컴포넌트의 이펙트 함수에서 데이터를 또 패치할 필요는 없다.
  • 중복되는 코드를 없애주자!

3. 중복 코드 삭제

  • Ingredients 컴포넌트에서 Search 컴포넌트의 이펙트함수와 동일한 데이터를 패치하는 이펙트함수를 없애면된다. 그러면 이렇게 중복이 발생하지 않는다.

  • 그러면 이렇게 네트워크 탭을 보면 Ingredients.json 데이터를 한 번만 받아오는 것을 확인할 수 있다.



✅ useRef(), ref


  • 현재 키가 입력될 때 마다 요청을 보내고 있는데, 이런 동작은 피하는 것이 좋다.
    이는 마치 서버에 스팸 메시지를 보내는 것과 비슷하다고 한다.

⚙️ 타이머 설정으로 키 입력 멈춘 후에만 http 요청 보내기


설정한 시간이 지났을 때 입력창 내용을 확인하여, 입력된 내용이 타이머가 시작된 시점에 입력된 내용과 같으면 사용자가 입력을 멈췄다는 뜻이되므로, 그 때만 요청 보내보자.

자바스크립트의 📚 클로저 때문에 enteredFilter는 타이머가 설정된 시점에 값이 고정된다. 타이머가 시작할 때 사용자가 입력했던 값으로 고정되어 있기 때문에, 타이머가 0.5초 뒤에 만료되면 현재 사용자가 입력하는 값과 다를 수도 있다.

  • 따라서 타이머가 시작할 때 사용자가 입력한 값인 enteredFilter와,
  • useRef()를 사용하여 현재 사용자가 입력하고 있는 값인 inputRef를 비교하여,
  • 이 둘이 같은 경우에만, 즉 사용자가 입력을 멈춘 경우에만 http 요청을 보내 재료를 검색할 수 있게 하면된다.
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;
  • 네트워크 탭을 보면, 키를 입력할 때마다 http 요청이 전송되는 것이 아니라 사용자가 입력을 멈춘 경우에만 http 요청이 가는 것을 확인할 수 있다.

useEffect()의 클린업 펑션


⚙️ 지나간 필요없는 타이머 삭제하여 메모리 효율 높이기

하지만 이 방법은 완벽한 방법이 아니다.
왜냐하면 이펙트가 실행될 때마다 새로운 타이머를 설정하고 있기 때문에, 이펙트는 입력이 변경될 때마다 계속 실행된다. 즉, 모두 따로 관리되는 아주 많은 타이머를 설정하고 있는 실정이다.

  • 이미 지나간 타이머는 필요 없기 때문에 이펙트가 실행될 때마다, 이전에 만든 타이머를 지워줘야 한다.
    setTimer()함수는 타이머 포인트를 제공하는데 이를 상수에 저장하여 사용할 수 있다.
const timer = setTimeout(() => {
  //...
}, 500);
  • 그리고 useEffect는 클린업 함수를 반환할 수 있다.
    반환한 클린업 함수는 동일한 useEffect()함수가 다시 실행되기 직전에 실행된다.
    따라서 여기서 clearTimeout()을 호출하여 인수에 timer를 넘기면 타이머를 해제할 수 있다.

    (참고)
    종속성 배열을 비워둘 경우, 즉 [](즉, 효과가 한 번만 실행됨)으로 설정한 경우, 컴포넌트가 마운트 해제될 때에만 클린업 펑션이 실행된다.

//http 요청 보내기
useEffect(() => {

  const timer = setTimeout(() => {
    if (enteredFilter === inputRef.current.value) {
      //...
    }
  }, 500);

  //클린업 펑션은 동일한 useEffect()가 실행되기 직전 실행된다.
  //clearTimeout()에 timer를 인수로 보내어 지나간 타이머를 제거할 수 있다.
  return () => {
    clearTimeout(timer);
  };
}, [enteredFilter, onLoadIngredients, inputRef]);

이렇게 하면 동일하게 동작하지만, 불필요한 지나간 타이머를 메모리에 유지하지 않기 때문에 메모리 효율은 더 좋아진다.

  • 현재 이펙트에서 실행한 클린업 함수는 다음 이펙트 실행전 동작한다.
  • 따라서 타이머는 새로운 타이머가 설정되기 직전에 지워지므로 실행중인 타이머는 1개로 유지할 수 있다.
  • 불필요한 타이머를 메모리에 유지하지 않으므로 메모리 효율은 더 좋아진다.

웹 서비스 구독하여 개발에 사용하는 경우,
어떤 값을 주기적으로 받아서 사용하는 경우에 이전에 받은 내용을 지우고 싶은 경우,
클린업 펑션을 사용하여 메모리를 효율적으로 사용하자.



⚙️ 데이터 삭제하기


현재 재료를 클릭하면 로컬에서는 삭제가 되고 있다.

// 재료 삭제
const removeIngredientHandler = (ingredientId) => {
  //로컬에서 삭제
  setUserIngredients((prevIngredients) =>
                     prevIngredients.filter((ingredient) => ingredient.id !== ingredientId)
                    )
};

하지만 새로고침을 하면 데이터 베이스에서 받아온 데이터가 다시 뜨기 때문에 데이터를 완전히 삭제하기 위해서는 데이터베이스의 데이터도 삭제해 줘야 한다.

  • 파이어 베이스의 노드를 확인해 보면 ingredients 다음은 각 재료의 id 값이다. 이 값을 지정하여 삭제할 쿼리를 보내면 된다.
  1. fetch()의 첫 번째 인수로는, 인수로 받은 재료의 아이디 값(ingredientId)을 지정하여 쿼리를 설정한다.

  2. fetch()의 두 번째 인수로 { method: "DELETE" } 객체를 설정한다.

  3. 그러고 나서 로컬에서 삭제하는 기능을 수행한다.
    삭제하는 http 요청이기 때문에 어떤 응답이 오는지는 중요하지 않고, 화면에 재료 목록이 없데이트되는 것이 중요하다.

// 재료 삭제
const removeIngredientHandler = (ingredientId) => {
  
  // 서버에서 삭제하는 기능
  fetch(
    `파이어베이스/ingredients/${ingredientId}.json`,
    // 노드 순서: ingredients/재료id
    // 삭제할 노드 지정하여 삭제 요청 보내기
    {
      method: "DELETE",
    }
  ).then((response) => {
    // 삭제하는 거라서 어떤 응답오는지는 중요하지 않고 화면에 재료 목록 업데이트하는게 중요
    // 로컬에서 삭제하는 기능
    setUserIngredients((prevIngredients) =>
                       prevIngredients.filter((ingredient) => ingredient.id !== ingredientId)
                      );
  });
};

⚙️ 사용자 경험 개선: 로딩 인디케이터 추가하기


데이터베이스에서 데이터를 가져오거나 추가하거나 삭제하는 등의 처리를 할 때, 딜레이가 발생한다. 이때 사용자 경험을 조금이라도 개선하기 위해서 로딩 인디케이터를 사용하여 데이터 로드 시 로딩 인디케이터가 화면에 표시되게 해보자.

  1. 로딩 스피너를 화면에 표시하기 위해 useState()를 사용하여 현재 로딩중인지에 대한 상태 값을 만든다.
    기본값은 false로 한다.
//로딩 스피너 화면에 표시하기: 현재 로딩중인지에 대한 상태
const [isLoading, setIsLoading] = useState(false);
  1. addHandler와 removeHandler에서 http 요청을 보내기 직전에 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( 
    //... 
  );
};
  1. 로딩 스피너를 표시하고자 하는 컴포넌트로 loading={isLoading} props을 보낸다.

  2. 로딩 스피너를 표시하고자 하는 컴포넌트의 부분에서 {props.loading && <LoadingIndicator />}loadingtrue인 경우 로딩 인디케이터 컴포넌트가 화면에 표시되게 설정한다.

⚙️ http 요청시 오류 핸들링


파이어베이스는 http요청시 오류 발생이 많지 않다고는 하나 http 요청시 혹시나 오류가 발생할 수 있으므로, try catch 구문을 사용하여 에러 발생시 에러 모달을 띄워보자.
fetch는 Promise 반환하므로 catch()로 에러를 잡을 수 있다.

  1. 에러를 핸들링하기 위해 먼저 error 상태를 관리할 수 있도록 useState()를 사용한다.
//에러 핸들링
const [error, setError] = useState();
  1. try 구문에 http 요청 및 응답 후 로컬 업데이트 하는 코드를 넣고, 에러 처리에 대한 코드는 catch 구문에 넣는다.
catch (error) {
  setError("Something went wrong!"); // 에러 메시지 업데이트
  setIsLoading(false); // 에러 발생 후, 로딩 스피너가 계속 돌아가지 않도록 멈추기
}
  1. clearError 핸들러를 만들어 에러모달을 끌 수 있게한다.
const clearError = () => {
  setError(null); //모달창 닫기> null은 거짓으로 취급됨
};
  1. 에러가 발생한 경우 모달창이 뜰 수 있도록 JSX를 작성한다.
return (
  <div className="App">
    {error && <ErrorModal onClose={clearError}>{error}</ErrorModal>}
    //...

📝 state 일괄 처리(batch)


catch (error) {
  setError("Something went wrong!");
  setIsLoading(false);
  //동일한 시점에 같은 함수 안에서 요청한 모든 상태 업데이트는 일괄 처리 된다(batch)
  //setError로 렌더링 한번 되고 setIsLoading로 렌더링 한 번 더 되는 것이 아니라
  //렌더링 한번 일어남
}

위의 catch()구문을 보면 setError(), setIsLoading()으로 state 업데이트가 연달아 업데이트 설정되고 있다.

setState()를 하면 컴포넌트가 리렌더링되는데, 그러면 위의 상황에서는 컴포넌트가 각각 한번 씩, 총 2번 리렌더링 될까?

정답은 아니다.

동일한 시점에 같은 함수 안에서 요청한 모든 상태 업데이트는 일괄 처리 된다(batch).
따라서 두 state 업데이트가 모여서 함께 처리되므로 컴포넌트 리렌더링은 한 번 일어난다.

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() 호출 시 스케줄됨)



✅ useReducer()로 더 복잡한 상태 관리하기


지금 여기에서 관리하고 있는 상태 3가지(userIngredients, isLoading, error)는 서로 연관되어 있다.
이 상태들은 모두 HTTP요청을 주고 받는 경우 설정되는 상태들이다.
현재 이 상태들을 따로 관리하고 있지만, catch()구문에서 처럼 setError(), setIsLoading()을 나란히 사용하여 동시에 상태를 업데이트하기도 한다. 리액트 일괄처리 매커니즘 덕분에 이렇게 사용해도 문제가 없다.

state가 다른 state에게 종속되어 있다면 상태관리는 좀 더 복잡해진다. 물론 지금은 그런 상황은 아니다.
하지만 이전 state를 기반으로 새로운 state를 업데이트해야 하는 경우, useState()보다 좀 더 나은 방법이 있는데, 바로 useReducer()를 사용하는 것이다.

useReducer()는 상태를 업데이트할 때 어떤 식으로 상태를 변경할 건지 정의할 수 있게 해준다.

리듀서를 사용하는데, 리듀서는 여러 개의 입력을 받아 하나의 결과를 반환하는 함수이다.
리듀서는 보통 컴포넌트 바깥에서 정의한다. 컴포넌트 내부와 딱히 연관성이 없기 때문에 괜찮다.
컴포넌트 내부에서 props으로 사용하는 경우에는 컴포넌트 내부에 작성하기도 한다고 한다.

1. 먼저 리듀서를 작성한다.

  1. 리듀서 함수는 리액트로부터 자동으로 2개의 인자를 받는다.
  • 첫 번째 인자: 현재 로컬에 저장된 state값
  • 두 번째 인자: 상태를 업데이트하는 액션
    액션은 객체 형태로, 타입에 따라 상태를 업데이트 하는 액션을 다르게 설정하면 된다.
  1. switch 문으로 action의 type에 따라 case를 정의하여 서로 다른 코드를 수행하도록 정의해 보자.
// ✅ 리듀서 함수 정의
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("여기로 오지 마세요!");
  }
};

2. useReducer()호출하기

  1. 컴포넌트 내부에서 useReducer()를 호출한다.
  • 첫 번째 인수: 설정한 리듀서 함수
  • 두 번째 인수: (옵셔널) 디폴트 state값을 설정할 수 있다.
    여기에서는 빈 배열을 넣어서 currentIngredients 상태가 초기에는 빈배열일 수 있게 하자.
  1. useReducer()함수는 상태 값과, 액션을 dispatch하는 함수를 반환한다. 구조분해 할당으로 설정하자.
const Ingredients = () => {
  //useReducer() 호출하여 초기화하기
  //인수로 리듀서 함수 받음, 두번째 인수는 옵션이긴 한데, 디폴트 state 넣을 수 있다. 여기엔 빈배열 넣자. 이 값이 currentIngredients로 전달된다.
  const [userIngredients, dispatch] = useReducer(ingredientReducer, []);
  //useReducer()는 userIngredients와 dispatch 함수를 반환한다.

3. setUserIngredients()를 사용하는 곳을 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를 한 곳에서 더 명확한 방식으로 관리할 수 있기 때문이다.

4. useState()를 가져오는 코드를 지운다.


isLoading 상태와 error 상태 함께 관리하기


isLoading 상태와 error 상태를 보면 http 요청 전송 시 사용되고 있는데 이 state들은 따로 관리되고 있긴하지만 연결된거나 마찬가지이다.
useState()를 사용하는 방식도 괜찮지만 useReducer()를 사용하여 함께 관리해보자.

1. isLoading 상태와 error 상태 함께 관리할 리듀서를 컴포넌트 바깥에 새로 만든다.

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("여기로 오지 마세요!");
  }
};

2. 컴포넌트 내부에서 useReducer()를 호출한다.

const [httpState, dispatchHttp] = useReducer(httpReducer, {
  //초기 값으로 객체 보내자.
  loading: false,
  error: null,
});

3. setIsLoading(), setError()를 호출한 것을 dispatchHttp()호출로 대체한다.

//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" });

4. 하위 컴포넌트로 보내주는 props가 변경되었기 때문에 바꿔준다.

<IngredientForm
  onAddIngredient={addIngredientHandler}
  //loading={isLoading}
  loading={httpState.loading}
  />

5. useState()를 가져오는 코드를 지운다.

이제 코드가 훨씬 간결해 졌다. 데이터를 변경하는 곳이 명확하니 데이터의 흐름도 더 명확히 보인다.

useRedux() 코드 전체

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;


✅ 복잡한 상태관리: useContext()로 작업하기


사용자가 로그인을 했을 때만 재료 항목을 보여줄 수 있도록 로그인 상태를 관리해보자.
auth 상태의 경우 많은 컴포넌트에 전반적으로 쓰여질 수 밖에 없다. 따라서 불필요한 prop drilling이 일어나게 되는데 이를 보다 잘 관리하기 위해 useContext()를 사용하여 상태관리를 할 수도 있다.

연습을 위해 레쯔고-!

1. 컨텍스트 파일 생성


1. 컨텍스트를 관리할 폴더를 만들고 auth 컨텍스트 파일을 생성한다.

📍 /src/context/auth-context.js

2. createContext()를 호출하여 컨텍스트를 생성하고, 컨텍스트 객체의 기본 값을 설정한다.

import { createContext } from "react";

//✅ 컨텍스트 갹체 ✅ AuthContext 생성하여 기본 값 설정
export const AuthContext = createContext({
  isAuth: false,  // 로그인 상태
  login: () => {}, // 로그인 핸들러
});

3. AuthContextProvider 이름의 컴포넌트를 생성하여 아래의 작업을 한다.

  1. useState()를 사용하여 사용자 로그인 상태를 생성
    const [isAuthenticated, setIsAuthenticated] = useState(false);
  2. 로그인 핸들러 생성
  3. 리턴 값으로 위에서 생성한 AuthContext 컨텍스트 객체에 .Provider를 붙여 리액트 컴포넌트를 반환할 수 있게 한다.
    <AuthContext.Provider />value 값을 받는데, 초기 값으로는 앞서 생성한 AuthContext 컨텍스트 객체의 기본 값을 받는다.
    따라서 여기에서 value 값으로 { 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;

2. index.js 설정


📍 /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>
);

3. App.js 설정 및 컨텍스트 사용 위해 useContext()훅 사용하기


📍 /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.js 버튼 클릭시 로그인 핸들러 작동 되게 하기 위해 컨텍스트 사용하기

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;


✅ useMemo()로 불필요한 리렌더링 피하여 성능 최적화하기: 핸들러 함수를 하위 컴포넌트로 전달할 때 일어날 수 있는 일


1. useCallback()으로 핸들러 감싸고, 핸들러를 props으로 받는 하위 컴포넌트를 React.memo()로 감싸는 방법


addIngredientHandler 핸들러 useCallback으로 감싸고 props으로 핸들러 받는 하위 컴포넌트 React.memo()로 감싸기


현재 <Ingredients /> 함수형 컴포넌트 안에는 addIngredientHandler핸들러 함수가 정의되어 있는데 이 핸들러 함수를 하위 컴포넌트인 IngredientForm으로 props으로 전달하고 있다.

이때 어떤 문제가 발생하느냐..!
언제든지 함수형 컴포넌트가 재구성되어 컴포넌트 함수 전체가 다시 실행되면, 이 핸들러 함수도 다시 생성된다. 그렇다..! 완전히 새로운 함수로 재생성 되어 버리는 것이다!
그러면 하위 컴포넌트로 props으로 이 새롭게 생성된 핸들러 함수가 전달되기 때문에 참조값이 바껴버린다. 그러면 하위 컴포넌트는 값이 실제로 바뀌지 않았더라도 어라! 값이 바뀌었네! 재생성해야지~ 이렇게 되어버리는 것이다.

(참고) useReducer 의 경우엔 다시 호출되더라도 재생성되지 않는다.
리액트는 리듀서가 이 컴포넌트에서 이미 초기화 됐다는 것을 감지하면 초기화된 값을 사용한다.

<IngredientForm> 컴포넌트에 콘솔 넣어서 실행해보자.

  • 원래는 login 상태가 바뀌거나 사용자가 폼에 입력할 때만 콘솔이 찍혀야 된다.
  • 그런데 새로 입력한 데이터가 목록에 표시될 때도 <IngredientForm> 컴포넌트가 재렌더링된다.

addIngredientHandler핸들러 함수를 props으로 받고 있는 하위컴포넌트인 <IngredientForm> 컴포넌트를 React.memo()로 감싸서 사용중인데도 그렇다.

아래쪽에 데이터를 표시하는데 위에 있는 다른 컴포넌트인 Form 컴포넌트를 재렌더링 할 필요는 없다.

loading 상태

  • false > true 로딩 중
  • true > false 로딩 완료
    따라서 버튼 누른 후, 총 2번만 렌더링 필요함

useCallback()으로 add 핸들러를 감싸주면 된다.

그러면 React.memo가 부모 컴포넌트가 재구성될 때 새로 받은 함수가 기존 함수와 같다는 것을 감지하여 자식 컴포넌트를 재구성하지 않는다.
물론 자식 컴포넌트에 있는 loading 상태가 변경될 때는 React.memo를 무시하고 자식 컴포넌트인 폼 컴포넌트가 재구성된다.

이렇게 하면 React.memo()가 제역할을 한다.

removeIngredientHandler 핸들러 useCallback으로 감싸고 props으로 핸들러 받는 하위 컴포넌트 React.memo()로 감싸기


삭제하는 핸들러도 마찬가지이다.
불필요한 렌더링이 한 번 더 일어나고 있다.

  • removeIngredientHandler 핸들러를 props으로 받는 하위 컴포넌트인 <IngredientList / > 컴포넌트의 의존성은 리무브 핸들러이다.
    따라서 리무브 핸들러를 콜백으로 감싸야 한다.

  • 그러고 나서 하위컴포넌트인 리스트 컴포넌트를 리액트 메모로 감싸면, 부모 컴포넌트가 재구성될 때 새로 받은 리무브핸들러 함수가 기존과 같다는 것을 감지하여 자식 컴포넌트인 리스트 컴포넌트를 재구성하지 않을 수 있다.

removeIngredientHandler 핸들러 useCallback으로 감싸고 props으로 핸들러 받는 하위 컴포넌트 React.memo()로 감싸면 불필요한 렌더링은 사라진다.

부모 컴포넌트: 📍 Ingredients.js

const Ingredients = () => {
  //..
  
  const removeIngredientHandler = useCallback(async (ingredientId) => {
    //...
  }, []);

return (
    <div className="App">
      //...
        <IngredientList
          ingredients={userIngredients}
          onRemoveItem={removeIngredientHandler}
        />
    </div>
  );
};

하위 컴포넌트: 📍 IngredientList.js

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;
  • 이제 목록이 바뀔때만 목록이 렌더링된다.

2. 이렇게 사용해도 되지만 useMemo() 훅을 사용해보자.


  • useCallback 훅은 함수를 저장하는데, 이 함수는 변하지 않아서 함수가 새로 생성되지 않는다.
  • useMemo 훅에는 을 저장한다.

useMemo()훅은 컴포넌트를 저장(memorizing)하는 또 다른 방식이다.

useMemo() 훅 사용하여 IngredientList 컴포넌트 반환하기

removeIngredientHandler 핸들러는 useCallback으로 감싸고, IngredientList 컴포넌트는 react.memo로 감싸지 않는다. 대신 컴포넌트를 useMemo()훅에서 반환하여 사용한다.

  1. useMemo()를 호출하고 함수를 인수로 넘기고, 반환값에 IngredientList 컴포넌트를 넣어주자.
    인수로 넘겨지는 함수는 우리가 저장하는 값이 아니라 리액트가 나중에 실행할 함수이다.
    이 함수가 반환하는 값이 우리가 저장할 값으로, 여기서는 IngredientList 컴포넌트를 반환하면 된다.

  2. 디펜던시로 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() 사용을 고려해 볼 수 있다. 그러면 컴포넌트가 렌더링 될 때 마다 다시 계산되지 않고, 정말 필요한 경우에만 다시 계산된다.

3. 최적화 하지 않는 것도 답이다.

이렇게 최적화 해도 되지만, 최적화 하지 않는 것도 답이다.
왜냐하면 리렌더링은 아주 강력한 기능이기 때문이다.

작은 컴포넌트의 경우 간단한 업데이트는 순식간에 리렌더링 된다.

  • 리렌더링은 언제나 가상 DOM을 대상으로 이루어 진다. 즉, 변경이 생긴다고 해서 무조건 실제 DOM을 다시 그리는게 아니다.
  • 물론 위에서 최적화 한 것 처럼, 가상 DOM의 리렌더링도 useCallback, react.memo, useMemo로 줄일 수 있지만 말이다.

아주 간단한 컴포넌트라면 React.memo를 추가하지 않는 편이 나을 수도 있다.
리액트가 항상 props의 변경사항을 확인해야 하기 때문에, 아주 작은 컴포넌트의 경우 변경사항을 확인하는 것 보다 리렌더링하는게 성능상 더 빠를 수도 있다.

  • 예를 들면 ErrorModal의 경우는 항상 리렌더링하게 둬도 괜찮다. 굳이 react.memo를 사용할 필요가 없다.

모든 것을 최적화할 필요는 없다 😇



✅ 커스텀 훅


  • 훅을 만들 때 가장 중요한 점은, 이름을 반드시 use-로 시작해야 한다는 점이다.

  • 훅은 일반 자바스크립트 함수이지만 리액트가 특별히 다룰 뿐이다.

  • 커스텀 훅에서는 state와 관련된 모든 훅을 사용할 수 있다.

  • 또한 커스텀훅을 사용하는 컴포넌트는 커스텀 훅에 작성된 코드를 본인 컴포넌트 안에 있는 것처럼 커스텀 훅을 실행 할 수 있다.

  • 훅의 동작 원리:
    그리고 커스텀 함수를 여러 컴포넌트가 공유하여 같은 코드에 같은 데이터 넣고 실행하는 것이 아닌, 각 함수형 컴포넌트가 커스텀 훅에 대한 각자의 스냅샷을 가진다. (아주 중요!)
    각자 stateful(state관련) 로직이 있지만, 각 로직의 생김새는 커스텀 훅을 사용하는 컴포넌트 마다 다르다.

useHttp 훅 만들기

http 요청 보내는 작업을 여러번 수행하고, 패턴도 항상 비슷하므로 커스텀 훅을 만들어 사용해보자.

  • 처음엔 SEND 요청 > 응답오면 RESPONSE 요청, 오류 발생시 ERROR 액션 디스패치한다.
  • 커스텀훅을 사용하여 이 동작이 구현된 코드를 컴포넌트끼리 공유할 수 있게 만들 수 있다.

중요한 점은 일어나는 동작이 해당 로직을 사용하는 컴포넌트의 state에 영향을 준다는 점이다. 따라서 일반 함수는 사용할 수 없다. 물론 요청 전송하는 일반함수를 만들어 요청 전송 로직을 구현할 수 있지만, 일반 하수에서는 이벤트를 디스패치하고 해당 함수를 호출한 컴포넌트의 state를 변경할 수는 없다.
그건 훅만 할 수 있다.

1. 훅 폴더와 커스텀 훅 파일 생성하기

📍 /src/hooks/htpp.js

2. useHttp 훅 만들기



사용자 정의 훅



컴포넌트 간에 데이터 공유하기



사용자 정의 훅 사용하기



profile
Always have hope🍀 & constant passion🔥

0개의 댓글