ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [Kent C. dodds - Epic React] React hooks(2) - HTTP Request & Error Handling
    Frontend 2020. 11. 1. 17:58

    Kent C. dodds의 Epic React 강의에서 React hooks - useEffect: HTTP requests 챕터를  정리해보려고 한다.

     

    일단 간단하게 API를 호출해서 화면에 API 응답으로 온 랜덤 강아지 이미지를 보여주는 예시를 만들어보려고 한다.

     

    const fetchRandomDog = (error) =>
      fetch("https://dog.ceo/api/breeds/image/random").then((data) => {
        if (error) {
          throw error;
        }
        return data.json();
      });

     

    그리고 에러가 나는 상황을 연출하기 위해 인자로 error가 넘어오면 error를 throw하고, 

    그 외의 경우에는 fetch받은 data를 JSON format으로 변환해서 return 시켜주기로 했다.

     

    import React from "react";
    import "./styles.css";
    
    const fetchRandomDog = (error) =>
      fetch("https://dog.ceo/api/breeds/image/random").then((data) => {
        if (error) {
          throw error;
        }
        return data.json();
      });
    
    export default function App() {
      const [loading, setLoading] = React.useState(false);
      const [image, setImage] = React.useState(null);
      const [error, setError] = React.useState(null);
    
      const fetchDog = (_, error) => {
        setLoading(true);
        fetchRandomDog(error).then(
          ({ message }) => {
            setImage(message);
            setLoading(false);
          },
          (error) => {
            setError(error);
            setLoading(false);
          }
        );
      };
    
      React.useEffect(() => {
        fetchDog();
      }, []);
    
      if (error) {
        return (
          <div className="App">
            <div>{error.message}</div>
            <button type="button" onClick={fetchDog}>
              Retry
            </button>
          </div>
        );
      }
    
      return (
        <div className="App">
          {loading ? (
            <div>is Loading...</div>
          ) : (
            <>
              <button type="button" onClick={fetchDog}>
                Change photo
              </button>
              <button
                type="button"
                onClick={(e) =>
                  fetchDog(e, new Error("oops! something went wrong"))
                }
              >
                Throw error
              </button>
              <img src={image} alt="random dog" />
            </>
          )}
        </div>
      );
    }
    

     

     

    이제 앱은 대충 이렇게 생겼다.

    Change photo 버튼을 클릭하면 매 번 새로운 사진을 HTTP request를 통해 가져오고 화면에 보여준다.

     

    위 상황에서 문제는 에러가 발생하고 난 이후인데,

    Throw error 버튼을 클릭하면 아래와 같은 에러뷰가 뜬다.

     

    이 상황에서 내가 Retry 버튼을 누르면 다시 fetch가 일어나서 정상적으로 사진을 가져와야하는데 

    fetch가 일어날때마다 가져온 데이터를 콘솔창에 출력하도록 한 후에 어떻게 되는지 살펴보자!

     

    const fetchDog = (_, error) => {
      setLoading(true);
      fetchRandomDog(error).then(
        ({ message }) => {
          console.log(message);
    
          setImage(message);
          setLoading(false);
        },
        (error) => {
          setError(error);
          setLoading(false);
        }
      );
    };

     

     

     

    분명 fetch가 일어남에도 불구하고 화면은 계속 에러 뷰만 보여주고 있다.

    그 이유는 fetch가 일어나서 image state가 업데이트되더라도

    기존에 업데이트된 error state가 리셋되지 않았기 때문이다.

     

    이걸 해결하려면 매번 fetch가 시작되기 전에 setError(null)을 통해 error state를 reset해 주어야 한다!

     

    const fetchDog = (_, error) => {
      setLoading(true);
      setError(null); // Reset Error
      fetchRandomDog(error).then(
        ({ message }) => {
          setImage(message);
          setLoading(false);
        },
        (error) => {
          setError(error);
          setLoading(false);
        }
      );
    };

     

    이 얼마나 귀찮은 일이란 말인가!

     

    그래서 이걸 해결하는 간단한 방법이 있는데,

    HTTP request의 상태를 나타내는 string 값을 value로 가지는 state를 하나 만들어서 처리해주는 것이다.

     

    • idle: no request made yet
    • pending: request started
    • resolved: request successful
    • rejected: request failed
    export default function App() {
      const [status, setStatus] = React.useState("idl");
      const [image, setImage] = React.useState(null);
      const [error, setError] = React.useState(null);
    
      const fetchDog = (_, error) => {
        setStatus("pending");
        fetchRandomDog(error).then(
          ({ message }) => {
            setImage(message);
            setStatus("resolved");
          },
          (error) => {
            setError(error);
            setStatus("rejected");
          }
        );
      };
    
      React.useEffect(() => {
        fetchDog();
      }, []);
    
      if (status === "rejected") {
        return (
          <div className="App">
            <div>{error.message}</div>
            <button type="button" onClick={fetchDog}>
              Retry
            </button>
          </div>
        );
      }
    
      return (
        <div className="App">
          {status === "pending" ? (
            <div>is Loading...</div>
          ) : (
            <>
              <button type="button" onClick={fetchDog}>
                Change photo
              </button>
              <button
                type="button"
                onClick={(e) =>
                  fetchDog(e, new Error("oops! something went wrong"))
                }
              >
                Throw error
              </button>
              <img src={image} alt="random dog" />
            </>
          )}
        </div>
      );
    }
    

     

    이렇게 처리해주면 error를 매 번 reset 해주지 않아도 원하는 데로 작동한다.

     

    이제 위 코드를 조금 더 개선한다면 state값을 하나의 객체로 저장하는 것이다.

     

    const [state, setState] = React.useState({
      status: "idl",
      image: null,
      error: null
    });
    
    const fetchDog = (_, error) => {
      setState({ status: "pending" });
      fetchRandomDog(error).then(
        ({ message }) => {
          setState({ status: "resolved", image: message });
        },
        (error) => {
          setState({ status: "rejected", error });
        }
      );
    };

     

    기존에는 fetch가 일어난 이후 setImage와 setStatus를 각각 호출하고,

    그 결과 2번의 re-render가 일어나게 된다.

     

    그런데 하나의 object에 state값을 저장하고 위와 같이 state를 update해준다면 딱 한 번만 re-render가 된다.

     

     

    ErrorBoundary

    만약에 Network 에러는 전혀 없었지만 다른 곳에서 예상치 못한 에러가 발생한 경우에는 어떻게 해야할까?

     

    다음과 같은 경우를 생각해보자.

    const [state, setState] = React.useState({
      status: "idl",
      image: { message: null },
      error: null
    });
    
    // ...
    
    return (
      <img src={image.message} alt="random dog" />
    );

     

    초기 image state 값이 위와 같은 객체이고, image.message를 사용해서 이미지를 표현해주고 있는데

    정상적으로 fetch가 이루어졌으나 데이터가 { message: '' } 와 같은 형태의 객체 대신에 null이 왔다고 가정해보자.

     

    const fetchDog = (_, error) => {
      setState({ status: "pending" });
      fetchRandomDog(error).then(
        (data) => {
          setState({ status: "resolved", image: null });
        },
        (error) => {
          setState({ status: "rejected", error });
        }
      );
    };

     

    물론 내가 그렇게 나오도록 API를 조작할 수는 없으니,

    null이 왔다고 가정한 후에 대충 위와 같이 image state 값으로 null을 업데이트해주었다.

     

    현재 Network 에러가 발생했을 때만 에러뷰가 보여지도록 설계되어 있기 때문에

    위 케이스에서는 유저에게 그 저 하얀색 공백 페이지만 보여질 것이다.

     

    바로 이런 경우에 필요한 것이 ErrorBoundary이다.

    안타깝게도 현재까지 ErrorBoundary는 class component로만 선언이 가능하다.

     

    class ErrorBoundary extends React.Component {
      state = { error: null };
    
      static getDerivedStateFromError(error) {
        return { error };
      }
    
      render() {
        const { error } = this.state;
        if (error) {
          return <this.props.FallbackComponent error={error} />;
        }
    
        return this.props.children;
      }
    }
    

     

    대충 위와 같은 구조로 만들 수 있다.

     

    초기 state에 error를 저장해주고,

    getDerivedStateFromError method를 통해 error가 발생했을때 인자로 error를 받을 수 있다.

    이 때 getDerivedStateFromError에서 return해주는 값으로 state가 update된다.

     

    그러면 error가 존재하면 인자로 넘어 온 에러뷰를 보여주고,

    error가 없을 때는 children을 return하도록 하면 된다!

     

    import React from "react";
    import ReactDOM from "react-dom";
    
    import App from "./App";
    
    class ErrorBoundary extends React.Component {
      state = { error: null };
    
      static getDerivedStateFromError(error) {
        return { error };
      }
    
      render() {
        const { error } = this.state;
        if (error) {
          return <this.props.FallbackComponent error={error} retry={() => window.reload()} />;
        }
    
        return this.props.children;
      }
    }
    
    const FallbackComponent = ({ error, retry }) => (
      <div className="App">
        <div>{error.message}</div>
        <button type="button" onClick={retry}>Retry</button>
      </div>
    );
    
    const rootElement = document.getElementById("root");
    ReactDOM.render(
      <React.StrictMode>
        <ErrorBoundary FallbackComponent={FallbackComponent}>
          <App />
        </ErrorBoundary>
      </React.StrictMode>,
      rootElement
    );
    

     

    이렇게 컴포넌트 상위에 ErrorBoundary를 감싸주면,

    ErrorBoundary 하위 컴포넌트에서 발생한 에러를 모두 감지하여 핸들링할 수 있다.

     

    export default function App() {
      const [state, setState] = React.useState({
        status: "idl",
        image: { message: null },
        error: null
      });
    
      const fetchDog = (_, error) => {
        setState({ status: "pending" });
        fetchRandomDog(error).then(
          (data) => {
            setState({ status: "resolved", image: data });
          },
          (error) => {
            setState({ status: "rejected", error });
          }
        );
      };
    
      React.useEffect(() => {
        fetchDog();
      }, []);
    
      const { status, image, error } = state;
    
      if (status === "rejected") {
        // Throw error to let ErrorBoundary handle this error
        throw error;
      }
    
      return (
        <div className="App">
          {status === "pending" ? (
            <div>is Loading...</div>
          ) : (
            <>
              <button type="button" onClick={fetchDog}>
                Change photo
              </button>
              <button
                type="button"
                onClick={(e) =>
                  fetchDog(e, new Error("oops! something went wrong"))
                }
              >
                Throw error
              </button>
              <img src={image.message} alt="random dog" />
            </>
          )}
        </div>
      );
    }
    

     

    이제 네트워크 에러가 발생했을 때,

    에러뷰를 App 컴포넌트 내에 만들 필요가 없이 그냥 throw하기만 하면,

    ErrorBoundary가 핸들링해준다!

     

     

    react-error-boundary

    ErrorBoundary를 만들어쓰기 귀찮다면 react-error-boundary라는 유용한 패키지가 있다.

    (강의에서는 아래 라이브러리 사용을 매우 권장했다!)

     

    function ErrorFallback({error, resetErrorBoundary}) {
      return (
        <div role="alert">
          <p>Something went wrong:</p>
          <pre>{error.message}</pre>
          <button onClick={resetErrorBoundary}>Try again</button>
        </div>
      )
    }
    
    function Bomb() {
      throw new Error('💥 CABOOM 💥')
    }
    
    function App() {
      const [explode, setExplode] = React.useState(false)
      return (
        <div>
          <button onClick={() => setExplode(e => !e)}>toggle explode</button>
          <ErrorBoundary
            FallbackComponent={ErrorFallback}
            onReset={() => setExplode(false)}
            resetKeys={[explode]}
          >
            {explode ? <Bomb /> : null}
          </ErrorBoundary>
        </div>
      )
    }
    

     

    onReset이라는 prop으로 state를 초기화하는 함수를 넣어주고,

    FallbackComponent에서 resetErrorBoundary라는 함수를 재시도 버튼과 연결해주면,

    재시도 버튼을 클릭했을때 state가 초기화된다.

     

    여튼 다양한 기능들을 제공해주니 나중에 한 번 프로젝트에 적용해봐야겠다.

    반응형

    COMMENT