ABOUT ME

비바리퍼블리카(토스)에서 Frontend Developer로 일하고 있습니다.

  • [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