Suspense와 ErrorBoundary

정호진·2023년 7월 16일
1

React

목록 보기
2/4

데이터 통신과 리액트에서 접근 방법

데이터 통신을 하면 크게 "성공, 로딩, 실패" 3개의 상태가 나타납니다. 성공했을 경우에는 받은 데이터를 기반으로 화면을 처리하면 되지만, 로딩, 실패했을 경우에 각각의 상태에 대한 안내를 해줘야 합니다. 그리고 리액트에서 데이터 통신에 대한 처리 방법을 크게 3가지로 나눌 수 있습니다.

Fetch-on-render

첫 번째 방법은 fetch-on-render입니다. 이 방법은 렌더링을 먼저 한 뒤, 생명주기 메서드를 통해 데이터 Fetch를 하는 방법으로, 상태의 결과에 따라 렌더링을 다르게 처리하는 방법입니다. 간단하게 사용이 가능하고 직관적으로 확인할 수 있습니다.

function ProfilePage() {
  const [user, setUser] = useState(null);

  useEffect(() => {
    fetchUser().then(u => setUser(u)); // 2
  }, []);

  if (user === null) {
    return <p>Loading profile...</p>; // 1
  }
  return ( 
    <>
      <h1>{user.name}</h1> // 3
      <ProfileTimeline /> // 4
    </>
  );
}

function ProfileTimeline() {
  const [posts, setPosts] = useState(null);

  useEffect(() => {
    fetchPosts().then(p => setPosts(p)); // 6
  }, []);

  if (posts === null) {
    return <h2>Loading posts...</h2>; // 5
  }
  return ( // 7
    <ul>
      {posts.map(post => (
        <li key={post.id}>{post.text}</li>
      ))}
    </ul>
  );
}

하지만, fetch-on-render는 크게 2가지 문제가 있습니다.

  1. 현재 코드에서는 데이터 fetch를 한 개밖에 하고 있지 않지만, 여러개가 될 수록 각각의 상태에 대한 loadingerror 상태 처리를 해줘야 합니다.
  2. 데이터 fetch가 동기적으로 실행되는 waterfal problem이 발생하게 됩니다. 위 코드는 주석에 달려 있는 순서대로 코드가 실행됩니다. 데이터 fetching에 동시성이 보장이 되지 않아서 각각의 데이터 fetching 시간 만큼 화면 렌더링이 지연됩니다.

즉, fetch-on-render는 간편한 렌더링 처리에는 용이할 수 있지만 그닥 권장되지는 않는 방법입니다.

Fetch-then-render

앞선 Fetch-on-render방식과 같이 화면을 렌더링 한 뒤 생명주기 메서드를 통해 데이터 Fetch합니다. 다만, 데이터 fetch 처리를 단 한번만 실행하도록 상위 컴포넌트에서 Promise.all을 사용합니다.

// Kick off fetching as early as possible
const promise = fetchProfileData();

function ProfilePage() {
  const [user, setUser] = useState(null);
  const [posts, setPosts] = useState(null);

  useEffect(() => {
    promise.then(data => { //2
      setUser(data.user);
      setPosts(data.posts);
    });
  }, []);

  if (user === null) {
    return <p>Loading profile...</p>; // 1
  }
  return (
    <>
      <h1>{user.name}</h1> // 3
      <ProfileTimeline posts={posts} /> // 4
    </>
  );
}

// The child doesn't trigger fetching anymore
function ProfileTimeline({ posts }) {
  if (posts === null) {
    return <h2>Loading posts...</h2>;
  }
  return (
    <ul>
      {posts.map(post => (
        <li key={post.id}>{post.text}</li>
      ))}
    </ul>
  );
}

Promise.all을 통해서 동시에 처리하기 때문에 앞선 Fetch-on-render에서 발생했던 waterfall problem은 해결할 수 있었습니다.
하지만, 하위 컴포넌트의 데이터를 상위 컴포넌트에서 fetch하기 때문에 관심사 분리 측면에서 그닥 좋지 못하고 로딩과 에러 처리를 할 때 각각의 상태에 따라 처리하기 힘들다는 단점이 있습니다.

또한 Fetch-on-render와 Fetch-then-render는 데이터 상태에 대한 처리를 해당 컴포넌트 내부에서 명령적으로 처리하기 때문에 리액트에서 권장하는 방식은 아닙니다.

Render-as-you-fetch

이 방법은 Suspense와 ErrorBoundary를 사용하는 방법인데, 렌더링과 동시에 데이터 fetching을 시작합니다. 그리고 데이터 Fetch에 따른 상태 처리의 경우에 loadingSuspense에, errorErrorBoundary에 위임합니다.

// This is not a Promise. It's a special object from our Suspense integration.
const resource = fetchProfileData();

function ProfilePage() {
  return (
    <Suspense fallback={<h1>Loading profile...</h1>}>
      <ProfileDetails />
      <Suspense fallback={<h1>Loading posts...</h1>}>
        <ProfileTimeline />
      </Suspense>
    </Suspense>
  );
}

function ProfileDetails() {
  // Try to read user info, although it might not have loaded yet
  const user = resource.user.read();
  return <h1>{user.name}</h1>;
}

function ProfileTimeline() {
  // Try to read posts, although they might not have loaded yet
  const posts = resource.posts.read();
  return (
    <ul>
      {posts.map(post => (
        <li key={post.id}>{post.text}</li>
      ))}
    </ul>
  );
}

사용 방법은 데이터를 Fetching하는 컴포넌트를 Suspense와 ErrorBoundary로 감싸주면 됩니다.

Render-as-you-fetch를 통해서 앞선 두 방법에서 발생한 문제를 해결할 수 있습니다.

  1. Suspense를 사용하면 데이터 렌더링을 동시에 처리할 수 있기 때문에 waterfall problem을 해결할 수 있습니다.
  2. 코드를 선언적으로 작성하기 때문에 컴포넌트간 역할이 분리되고 의존성이 낮아집니다.
  3. 상태에 따른 분기처리를 하지 않아도 돼서 코드의 복잡도가 낮아지고 간략해집니다.

Suspense & ErrorBoundary

그렇다면 Render-as-you-fetch에서 언급한 SuspenseErrorBoundary가 무엇일까요?

Suspense

Suspense를 사용하면 자식이 로딩을 완료할 때까지 폴백을 표시할 수 있습니다. - React 공식 문서

Suspense는 하위 컴포넌트에서 발생한 예외(Promise)를 catch해서 fallback UI(대체 UI)를 띄워줍니다. jsx를 return 하지 않고 promisethrow 함으로써 현재 데이터를 받아오는 중이라고 상위 컴포넌트에 알립니다. Suspense는 React 내부에 이미 작성되어 있기 때문에 다음과 같이 손쉽게 가져다가 쓸 수 있습니다.

import { Suspense } from 'react';

<Suspense fallback={<Loading />}>
	<Components/>
</Suspense>

인자로 넘겨받는 fallback에는 로딩 상태일때 듸워줄 UI를 할당해 주면 됩니다.

ErrorBoundary

An error boundary is a special component that lets you display some fallback UI instead of the part that crashed—for example, an error message. - React 공식 문서

ErrorBoundary 역시 하위 컴포넌트에서 발생한 예외(Error)를 catch해서 대체 UI를 띄워줍니다. Suspense와 달리 Errorthrow한다는 차이점이 있습니다. 또한 ErrorBoundary는 리액트 내부에 따로 내장되어 있지 않기 때문에 프로젝트 내부에서 직접 작성을 하거나 react-error-boundary를 사용하면 직접 구현하지 않고 사용이 가능합니다.

class ErrorBoundary extends React.Component {
  constructor(props) {
    super(props);
    this.state = { hasError: false };
  }

  static getDerivedStateFromError(error) {
    // Update state so the next render will show the fallback UI.
    return { hasError: true };
  }

  componentDidCatch(error, info) {
    // Example "componentStack":
    //   in ComponentThatThrows (created by App)
    //   in ErrorBoundary (created by App)
    //   in div (created by App)
    //   in App
    logErrorToMyService(error, info.componentStack);
  }

  render() {
    if (this.state.hasError) {
      // You can render any custom fallback UI
      return this.props.fallback;
    }

    return this.props.children;
  }
}

ErrorBoundary의 핵심 요소는 getDerivedStateFromErrorcomponentDidCatch입니다. 에러바운더리를 hooks로 만든다면 더욱 좋았겠지만, 리액트 내장 hooks 중에서 위에 두 메서드를 지원하지 않기 때문에 클래스로만 사용할 수 있습니다. 메서드의 역할은 다음과 같습니다.

  • getDerivedStateFromError: 하위 컴포넌트에서 에러를 발생시켰을때 호출되는 메서드로, render-phase에서 호출합니다. 따라서 순수 함수만을 반환해야 합니다. 주로 render에서 사용할 errorstate를 업데이트 하고, 에러가 발생하지 않는다면 null을 반환합니다.
  • componentDidCatch: 하위 컴포넌트에서 에러를 발생시켰을때 호출되는 메서드로, commit-phase에서 호출합니다. 따라서 side-effect를 허용하며, 주로 에러 로그를 기록하는 역할을 해당 메서드에서 수행합니다.

클래스 명을 다르게 하더라도, 혹은 일반적인 UI 클래스더라도 위에 두 메서드만 작성하게 된다면 ErrorBoundary로써의 역할을 하게 됩니다. ErrorBoundart에서도 fallback UI를 전달 받아서 에러 상태일 때 띄워줄 수 있는데, TypeScript를 통해서 ErrorBoundary를 구현할 때 prop를 다음과 같이 작성하면 좋습니다!

interface ErrorBoundaryProps extends PropsWithChildren {
  fallback: React.ReactNode;
  onError?: (error: Error, info: { componentStack: string }) => void;
  onReset?: (details: { reason: 'imperative-api'; args: any[] }) => void;
}

추가적으로,SuspenseErrorBoundary를 사용하기 위해서는 자식에서 값을 throw 해야 한다고 했는데, 데이터를 fetch 할 때 다음과 같은 형태로 promise를 감싸주면 됩니다.

function wrapPromise(promise) {
  let status = "pending";
  let result;
  let suspender = promise.then(
    (r) => {
      status = "success";
      result = r;
    },
    (e) => {
      status = "error";
      result = e;
    }
  );
  return {
    read() {
      if (status === "pending") {
        throw suspender;
      } else if (status === "error") {
        throw result;
      } else if (status === "success") {
        return result;
      }
    }
  };
}

앞서 언급한 SuspenseErrorBoundary는 명령형으로 처리하던 상태를 선언형으로 처리할 수 있도록 도와주고, 자식에서 발생한 예외를 발생한 부분에서 처리하지 않고 상위 컴포넌트로 위임함으로써 처리하는 공통점을 갖고 있습니다. 그렇다면 이러한 형태의 유사성은 어디서부터 영감을 받은 것일까요?

This “suspends” the execution. React catches that Promise, and remembers to retry rendering the component tree after the thrown Promise resolves.
This isn’t an algebraic effect per se, even though this trick was inspired by them. - Dan Abramov

React팀의 Dan Abramov는 Suspense대수적 효과에 영향을 받았다고 언급했습니다.

또한, Suspense개념을 고안한 Sebastian Markbåge 역시 Suspense의 작동원리를 간략하게 요약한 한 링크에서
Poor man's algebraic effects. 라고 언급을 하며 대수적 효과와 Suspense가 서로 관련이 있음을 암시했습니다.


대수적 효과

그렇다면 Algebraic effects (대수적 효과)란 무엇일까요? Matija Pretnar가 작성한 An Introduction to Algebraic Effects and Handlers Invited tutorial paper의 도입부에서 대수적 효과에 대해 다음과 같이 설명하고 있습니다.

Algebraic effects are an approach to computational effects based on a premise that impure behaviour arises from a set of operations such as get & set for mutable store, read & print for interactive input & output, or raise for exceptions. This naturally gives rise to handlers not only of exceptions, but of any other effect, yielding a novel concept that, amongst others, can capture stream redirection, backtracking, co-operative multi-threading, and delimited continuations - Matija Pretnar

간단하게 축약해 보자면, 대수적 효과는 순수하지 않은 행동들이 일련의 활동에서 발생된다는 점을 전제하고, 그 행동들을 적절하게 처리할 수 있는 handler가 주어진 상태로 computational effects에 대해 접근하는 방식이다.

상당히 추상화된 의미로 사용되는 이 문구를 리액트 렌더링 과정에 맞춰서 해석을 해보겠습니다.

  • 순수하지 않은 행동: UI만 렌더링 하는 컴포넌트에서 순수하지 않은 행동은 비동기 통신과 일반적은 명령형 코드들로 볼 수 있다. 따라서, 일반적인 명령형 코드는 hooks로 비동기 통신은 SuspenseErrorBoundary를 통해서 해결할 수 있다.
  • computational effects: 서로 다른 환경에서 발생하는 상호작용

즉, 대수적 효과를 Suspense 개념에 대입해 보면 다음과 같습니다.

서로 다른 환경 (부모 컴포넌트와 자식 컴포넌트)에서 발생하는 순수하지 않은 행동 (data fetching)에 대한 handler(throw & fallback)가 주어져 그 부수 효과들을 핸들링 하는 것을 의미합니다.

사실 Suspense에서 대수적 효과가 완벽하게 적용이 된 것은 아닙니다. 대수적 효과의 접근 방식을 약간 차용한 것으로 볼 수 있습니다. (약간의 함수형 프로그래밍 성격이 대수적 효과와 연관이 있어보이긴 합니다.)

대수적 효과를 좀 더 축약해 보자면 "제어의 역전 현상"이라고 할 수 있습니다. 이 얘기만 듣고서는 어떤 의미인 감이 잘 오지 않을 수 있습니다. 한가지 예시를 들어보겠습니다. JavaScript에서 흔하게 살펴볼 수 있는 대수적 효과는 바로 try ~ catch문 입니다.


const shoMeTheMoney = (name:string) =>{
  if(name === "clean"){
    return 100;
  }
 
  throw Error("name is not validate")
}


try {
  showMeTheMoney("hozzi")
}catch(error){
  console.log(error)
}

인자로 전달 받은 값을 검사해서 일치한다면 값을 반환하고 그게 아니라면 에러를 던지는 함수를 작성해 봤습니다. try문 안에서 함수를 실행시켰을 때 에러가 발생했을 때에 대한 처리는 전혀 하지 않았습니다. 에러가 발생했을 때의 제어권을 catch문으로 넘긴 것입니다.

SuspenseErrorBoundary를 렌더링의 try~catch문이라고 생각을 하면 좀 더 이해하기 쉬울것입니다. 단, Suspense의 경우에는 catch안에 있는 메서드가 실행되고난 다음에 다시 try문으로 돌아와서 throw된 부분부터 함수가 실행된다는 특징이 있습니다.

이전에 SuspenseErrorBoundary는 하위 컴포넌트에서 jsx를 return 하지 않고 예외를 throw한다고 했습니다. 그렇다면 리액트는 어떻게 throw한 예외를 처리할 수 있는것일까요? 그 답은 Sebastian Markbåge가 작성한 SynchronousAsync.js에 있습니다.


어떻게 throw한 예외를 잡을까?

SynchronousAsync

let cache = new Map();
let pending = new Map();

function fetchTextSync(url) {
  if (cache.has(url)) {
    return cache.get(url);
  }
  if (pending.has(url)) {
    throw pending.get(url);
  }
  let promise = fetch(url).then(
    response => response.text()
  ).then(
    text => {
      pending.delete(url);
      cache.set(url, text);
    }
  );
  pending.set(url, promise);
  throw promise;
}

위 코드는 cacheurlreturn하거나 promisethrow 합니다.

async function runPureTask(task) {
  for (;;) { 
    try {
      return task(); // task를 return 하면 반복문 종료
    } catch (x) {
      if (x instanceof Promise) {
        await x; // promise를 catch 해서 resolve 시도
      } else {
        throw x;
      }
    }
  }
}

이 메서드가 Suspense의 핵심 코드를 간략하게 줄여놨습니다. taskreturn 하게 된다면 이 반복문이 종료되지만 그게 아니라면 계속해서 task를 시도합니다. 그리고 이 과정에서 taskreturn 하기 전까지 runPureTask를 호출한 코드는 무한한 대기(Suspense)에 빠지게 됩니다.

그렇다면 React내부에서는 어떻게 구현되어 있는지 한번 보겠습니다. 이번에 뜯어보는 React코드는 18.2.0 버전입니다. 이후에 업데이트 될 수록 코드가 달라질 수 있습니다.

React Sourece Code

React는 렌더링 과정에서 renderRootConcurrent라는 메서드를 호출하게 됩니다. 그 뒤로 다음과 같은 순서를 통해서 컴포넌트를 렌더링 합니다.

  • workLoopConcurrent: workInProgress가 null이 될때까지 반복문 호출. shouldYield를 통해 렌더링 우선순위 확인
  • performUnitOfWork: beginWork를 호출하고, 마지막에 completeUnitOfWork를 호출
  • beginWork: workInProgress.tag에 알맞는 컴포넌트 호출
  • 여기서 Fiber를 return 하지 않고 예외를 throw! (이 사이에 과정이 상당히 복잡합니다.)

beginWork에서 호출하려 했던 함수가 throw를 발생시키면 어디선가 catch를 통해서 처리해아 합니다. 그리고 렌더링 과정에서 catch를 하는 부분이 renderRootConcurrent입니다.

function renderRootConcurrent(root: FiberRoot, lanes: Lanes) {
outer: do {
    // ...
      if (__DEV__ && ReactCurrentActQueue.current !== null) {        
        workLoopSync();
      } else {
        workLoopConcurrent();
      }
      break;
    } catch (thrownValue) {
      handleThrow(root, thrownValue);
    }
  } while (true);

위 코드에서 do~while문과 try~catch문이 존재합니다. 이제 workLoopConcurrent()에서 throw된 예외를 catch문에서 잡에서 handleThrow를 실행시키게 됩니다.

function handleThrow(root: FiberRoot, thrownValue: any): void {
  if (thrownValue === SuspenseException) {
  	    workInProgressSuspendedReason = 
          	shouldRemainOnPreviousScreen() &&
 			!includesNonIdleWork(workInProgressRootSkippedLanes) &&
          	!includesNonIdleWork(workInProgressRootInterleavedUpdatedLanes)
          ? // Suspend work loop until data resolves
          SuspendedOnData:SuspendedOnImmediate;
  }
}

throwValue의 값이 SuspenseException이라면 ReactFiberWorkLoop에 전역적으로 선언되어 있는 workInProgressSuspendedReasonSuspendedOnData가 할당된 다음에 종료됩니다. 그리고 반복문이기 때문에 다시 do문으로 들어갑니다.

	case SuspendedOnData: {
      const thenable: Thenable<mixed> = (thrownValue: any);
      //...
      const onResolution = () => {
        //...
        // Ensure the root is scheduled. We should do this even if we're
        // currently working on a different root, so that we resume
        // rendering later.
        ensureRootIsScheduled(root);
      };
      thenable.then(onResolution, onResolution);
      break outer;
    }

SuspendedOnData인 경우에 위와 같은 메서드가 실행이됩니다. thenable이 resolve된 다음에 onResolution을 실행하게 되는데 이 함수의 마지막에 현재 root가 React 스케줄러에서 throw된 부분부터 실행될 수 있도록 보장해주고 있습니다. 즉, fallback UI의 렌더링이 끝난 다음에 throw된 ChildComponent부터 렌더링이 될 수 있도록 하게 합니다.

그렇다면 예외를 처리하는 방법에 대해 알았다면 Suspense Component가 어떻게 렌더링 되는지 한번 보겠습니다.

Suspense Component

아까 beginWork에서 workInProress.tag에서 Suspense Component인 경우에 updateSuspenseComponent 메서드가 호출됩니다.

updateSuspenseComponent내부에는 showFallback이라는 명확한 변수가 존재합니다. 이제 이 값이 true라면 fallback Component가 함께 렌더링 되고 false라면 child Component만 렌더링 됩니다.

여기서 중요한 점은 fallback Component가 렌더링 될 때 child Component역시 같이 렌더링 된다는 점입니다.


function mountSuspenseFallbackChildren(
  workInProgress: Fiber,
  primaryChildren: $FlowFixMe,
  fallbackChildren: $FlowFixMe,
  renderLanes: Lanes,
) {
  const mode = workInProgress.mode;
  const progressedPrimaryFragment: Fiber | null = workInProgress.child;

  const primaryChildProps: OffscreenProps = {
    mode: 'hidden',
    children: primaryChildren,
  };

  let primaryChildFragment;
  let fallbackChildFragment;
  if (
    (mode & ConcurrentMode) === NoMode &&
    progressedPrimaryFragment !== null
  ) {
    // In legacy mode, we commit the primary tree as if it successfully
    // completed, even though it's in an inconsistent state.
    primaryChildFragment = progressedPrimaryFragment;
    primaryChildFragment.childLanes = NoLanes;
    primaryChildFragment.pendingProps = primaryChildProps;

    if (enableProfilerTimer && workInProgress.mode & ProfileMode) {
      // Reset the durations from the first pass so they aren't included in the
      // final amounts. This seems counterintuitive, since we're intentionally
      // not measuring part of the render phase, but this makes it match what we
      // do in Concurrent Mode.
      primaryChildFragment.actualDuration = 0;
      primaryChildFragment.actualStartTime = -1;
      primaryChildFragment.selfBaseDuration = 0;
      primaryChildFragment.treeBaseDuration = 0;
    }

    fallbackChildFragment = createFiberFromFragment(
      fallbackChildren,
      mode,
      renderLanes,
      null,
    );
  } else {
    primaryChildFragment = mountWorkInProgressOffscreenFiber(
      primaryChildProps,
      mode,
      NoLanes,
    );
    fallbackChildFragment = createFiberFromFragment(
      fallbackChildren,
      mode,
      renderLanes,
      null,
    );
  }

  primaryChildFragment.return = workInProgress;
  fallbackChildFragment.return = workInProgress;
  primaryChildFragment.sibling = fallbackChildFragment;
  workInProgress.child = primaryChildFragment;
  return fallbackChildFragment;
}

mountSuspenseFallbackChildrenfallbackFragment를 반환하는 메서드인데, primaryChildmodehidden으로 설정한 뒤에

  const primaryChildProps: OffscreenProps = {
    mode: 'hidden',
    children: primaryChildren,
  };  

fallbackChildFragmentprimaryChildFragment를 형제 관계로 설정합니다. 이를 통해 Suspense를 사용하면 하위 컴포넌트들도 함께 렌더링 된다는 것을 알 수 있습니다.

showFallback이 false인 경우에, 저 modevisible로 설정하고 렌더링을 수행합니다. 저 hiddenvisible은 css에서 display:hidden & visible과 같은 의미를 내포하고 있습니다.

이제 하위 컴포넌트에서 throw한 예외를 어떻게 잡는지에 대해서 알아보고 Suspense Component의 렌더링이 어떻게 일어나는지 살펴봤습니다. 이제는 ErrorBoundary는 어떻게 렌더링 되는지 한번 살펴보겠습니다.


여담으로 beginWork에서 SuspenseException을 throw하는 과정

  1. beginWork에 있는 workInProgress.tag
  2. reconcileChildren
  3. reconcileChildFibers
  4. reconcileChildFibersImpl
  5. unwrapThenable
  6. trackUsedThenable
  7. throw SuspenseException!!

ErrorBoundary

에러 바운더리 역시 renderRootConcurrent에서 throw한 Error를 catch하게 됩니다.

else {
    // This is a regular error.
    const isWakeable =
      thrownValue !== null &&
      typeof thrownValue === 'object' &&
      typeof thrownValue.then === 'function';

    workInProgressSuspendedReason = isWakeable
      ? // A wakeable object was thrown by a legacy Suspense implementation.
        // This has slightly different behavior than suspending with `use`.
        SuspendedOnDeprecatedThrowPromise
      : // This is a regular error. If something earlier in the component already
        // suspended, we must clear the thenable state to unblock the work loop.
        SuspendedOnError;
  }

handleThrow에서 thrownValue가 아무것도 아닌 경우에 workInProgressSuspendedReason에는 SuspendedOnError가 할당됩니다. 그리고 do~while loop로 돌아와서 SuspendedOnError가 할당된 case문이 실행됩니다.

case SuspendedOnError: {
  // Unwind then continue with the normal work loop.
  workInProgressSuspendedReason = NotSuspended;
  workInProgressThrownValue = null;
  throwAndUnwindWorkLoop(unitOfWork, thrownValue);
  break;
}

해당 메서드에서 throwAndUnwindWorkLoop 메서드가 실행되고, 해당 메서드 내부에 있는 throwException을 호출합니다. 이 throwException은 근처에 가장 가까운 ErrorBoundary를 찾습니다.

 case ClassComponent:
  // Capture and retry
  // Schedule the error boundary to re-render using updated state
  const update = createClassErrorUpdate(
    workInProgress,
    errorInfo,
    lane,
  );
  enqueueCapturedUpdate(workInProgress, update);
  return;
  }

그리고 throwException이 실행된 메서드에서 또 workInProgress.tag의 값을 비교합니다. 여기서 ErrorBoundary는 ClassComponent이기 때문에 해당 메서드가 호출됩니다. 여기서 createClassErrorUpdate가 실행됩니다.

if (typeof getDerivedStateFromError === 'function') {
    const error = errorInfo.value;
    update.payload = () => {
      return getDerivedStateFromError(error);
 	}
};

update에 getDerivedStateFromError가 할당되는데, 이 메서드가 우리가 ErrorBoundary를 생성할 때 꼭 작성해야한다고 했던 그 부분입니다. 이제 해당 부분을 queue에 담은 다음에 workLoopConcurrent를 실행하게 됩니다.

그리고 ErrorBoundaryclassComponent이기 때문에 beginWork()에서 updateClassComponent를 실행하게 되고, finishClassComponent를 호출합니다.

  const instance = workInProgress.stateNode;
if (
    didCaptureError &&
    typeof Component.getDerivedStateFromError !== 'function'
  ) {
  } else {
    //...
    } else {
      nextChildren = instance.render();
    }

finishClassComponent에서 render()메서드를 통해 ErrorBoundary에서 선언했던 render()가 실행되고, 저희가 작성했던 error의 상태에 따라 fallbackUI가 나타나게 되는것 입니다.


정리

  • 데이터를 fetching하고 로딩,에러 상태를 관리하는 방법은 각각의 상태를 선언적으로 처리할 수 있는 render-as-you-fetch 방법이 좋다.
  • 해당 방법은 Suspense와 ErrorBoundary를 통해서 구현할 수 있다.
  • Suspense & ErrorBoundary는 대수적 효과에 영향을 받은 기술들이다.
  • 대수적 효과란, 서로 다른 환경에서 발생하는 순수하지 않은 상호작용을 주어진 handler를 통해 관리하는 방법이다.
  • 자식 컴포넌트에서 throw한 값을 받는 방법은 React 내부에 있는 renderRootConcorrent 메서드속 do ~ while loop와
    try~catch문을 통해서 처리할 수 있다.
  • Suspense의 경우에는 fallback Component가 렌더링 됨과 동시에 Child Component가 동시에 렌더링 되는 것을 알 수 있다.
  • ErrorBoundary는 ClassComponent로 작성해야 하고, getDerivedStateFromError 안에서 상태를 업데이트 해줘야 한다.

그리고 Suspense의 개념에 대해서 좀 더 자세히 알아보고 싶으신 분들은

  • React Fiber
  • React rendering
  • React Concurrent Mode
  • React Lane Model
  • React Scheduling

등에 대해서 알아보시면 더욱 도움이 될 것입니다.


참조

대수적 효과

Dan Abramov의 대수적 효과
콴다 팀 블로그

How to catch exception in React

SynchronousAsync.js
React Source Code
how does errorboundary work
콴다 팀 블로그

0개의 댓글

관련 채용 정보