Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

MyReact 컨택스트 개선 #1

Merged
merged 3 commits into from Apr 14, 2024
Merged

MyReact 컨택스트 개선 #1

merged 3 commits into from Apr 14, 2024

Conversation

jeonghwan-kim
Copy link
Owner

@jeonghwan-kim jeonghwan-kim commented Apr 14, 2024

수강자 분께서 MyReact로 컨택스트를 구현하는 과정에 질문을 주셨습니다.

간단히 말해 "왜 MyReact의 컨택스트를 사용하면 컴포넌트를 두 번 렌더하는가?" 입니다. 컨택스트 값이 바뀌지 않으면 한 번만 렌더링 되어야하는데 말이죠.

재현하기

문제를 재현해 봅시다. 2-hook/src/App.jsx 파일을 아래 코드로 교체합니다.

import MyReact from "./lib/MyReact";
import React from "react";

// 카운트 컨택스트를 만든다.
const countContext = MyReact.createContext({
  count: 0,
  setCount: () => {},
});

// 컨택스트로 값을 제공한다.
class CountProvider extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      count: 0,
    };
  }

  render() {
    const value = {
      count: this.state.count,
      setCount: (nextValue) => this.setState({ count: nextValue }),
    };
    return (
      <countContext.Provider value={value}>
        {this.props.children}
      </countContext.Provider>
    );
  }
}

// 컨택스트를 통해 값을 사용한다.
const Count = () => {
  const value = MyReact.useContext(countContext);
  console.log("Count", value);
  return <div>{value.count}</div>;
};

// 컨택스를 사용하는 컴포넌트를 조합한다.
export default () => (
  <CountProvider>
    <Count />
  </CountProvider>
);

countContext를 만듭니다. 값을 제공하는 CountProvider와 값을 소비하는 Count 컴포넌트를 만들어 조합했습니다. 컨택스트를 소비하는 Count 컴포넌트를 렌더링할 때마다 로그를 출력할 것입니다.

npm start --workspace 2-hook 으로 개발 서버를 띄고오 브라우져로 접속해서 확인해 봅니다.

Count Object { count: 0, setCount: setCount()}
Count Object { count: 0, setCount: setCount(nextValue) }

로그가 두 번 찍혔습니다. 컨택스트를 소비하는 컨슈머 컴포넌트가 두 번 렌더링 되었습니다.

테스트로 재현하기

테스트 코드로 문제를 재현해 보겠습니다. 문제를 곧장 해결할수도 있습니다만 테스트를 만들어 두는 것이 좋겠습니다. 나중에 이 부분을 바꾸더라도 문제 재발을 예방할 수 있기 때문입니다.

2-hook/test/MyReact.test.jsx 파일을 만들어 테스트 코드를 작성합니다. cc0dbeb

npm test --workspace 1-hook 명령어로 테스트를 실행합니다.

 FAIL  test/MyReact.test.jsx
  useContext
    ✕ 컴포넌트가 한 번만 렌더 된다. (22 ms)

  ● useContext › 컴포넌트가 한 번만 렌더 된다.

    expect(received).toBe(expected) // Object.is equality

    Expected: 1
    Received: 2

      29 |
      30 |     expect(getByText(1)).toBeTruthy();
    > 31 |     expect(consumerRenderCount).toBe(1);
         |                                 ^
      32 |   });
      33 | });
      34 |

      at Object.toBe (test/MyReact.test.jsx:31:33)

Test Suites: 1 failed, 1 total
Tests:       1 failed, 1 total
Snapshots:   0 total
Time:        0.985 s, estimated 1 s

테스트가 실패합니다. 자 이제 문제를 해결할 준비를 마쳤습니다.

문서 확인

리액트 컨택스트를 잘못 이해한 것은 아닐까요? 공식 문서를 다시 확인해 보았습니다.

createContext(defaultValue) 의 인자가 기본 값입니다. 리액트 렌더 트리에서 컨택스트 제공자를 찾지 못할 경우 사용할 값입니다. 컨택스트를 사용할 때 실수로 제공자 컴포넌트를 잊을 경우 최수의 수단으로 이 값을 사용한다고 합니다.

우리가 구현했던 MyReact는 이 값을 초기값(initialValue)으로 사용했습니다.

// 초기값이란 의미로 인자를 받았다.
function createContext(initialValue) {
  // 이 값으로 이벤트 에미터를 만들었다.
  // 구독 객체는 초기 값을 제공받을 것이다.
  const emitter = createEventEmitter(initialValue);

  function Provider({ value, children }) {
    // Provider의 이 부수효과로 변경된 값을 이벤트 에미터로 전달한다.
    // 구독 객체는 이 변경을 감지할 것이다(소비자 컴포넌트라면 렌더 유발). 
    React.useEffect(() => {
      emitter.set(value);
    }, [value]);

    return <>{children}</>;
  }

  return {
    Provider,
    emitter,
  };
}

initialValue 인자로 이벤트 에미터를 만들었습니다. 곧장 구독 객체에게 이 값이 전달될 것입니다. Provider는 props.value가 바뀔 때 마다 이 값을 구독 객체에게 전달합니다. 구독 객체가 리액트 컴포넌트라면 두 번 렌더링 될 것입니다. 문제의 원인입니다.

해결하기

createContext의 인자를 initialValue가 아니라 defaultValue로 바꾸겠습니다.

// defaultValue로 인자 이름을 바꾸었다.
function createContext(defaultValue) {
  // 값이 들어올때 까지 이벤트에미터 객체 생성을 늦춘다.
  let emitter;

  function Provider({ value, children }) {
    // Provider 이벤트 에미터를 생성하는 역할을 한다.
    if (!emitter) {

      // value 프롭으로 이벤트에미터를 만든다.
      // 이전에는 createContenxt의 인자로 만들었다. 다른 점이다.
      emitter = createEventEmitter(value);
    }

    React.useEffect(() => {
      emitter.set(value);
    }, [value]);

    return <>{children}</>;
  }

  // 이벤트 에미터에서 값을 조회한다. 없을 경우 기본값을 전달한다.
  function getValue() {
    return emitter ? emitter.get() : defaultValue;
  }

  // 에미터를 구독한다
  function on(handler) {
    emitter?.on(handler);
  }

  // 에미터 구독을 해지한다.
  function off(handler) {
    emitter?.off(handler);
  }

  // 에미터 객체를 직접 반환하지 않았다.
  // 에미터가 없을 경우 기본값를 사용하기 위해 래핑한 함수를 제공했다.
  return {
    Provider,
    getValue,
    on,
    off,
  };
}

defaultValue 인자로 이벤트 에미터 객체를 만들지 않고 Provider에게 객체 생성 역할을 맡겼습니다. 컨택스에 제공할 값을 props.value로 받는 함수이기 때문입니다.

이전에는 에미터 객체가 항상 있다고 가정하고 값을 직접 조회했습니다. 이제 이벤트 에미터를 Provider가 생성하기 때문에 객체가 없는 경우도 챙겨야합니다. getValue()는 이벤트 에미터 객체를 확인하고 값을 조회하거나 기본 값을 제공하는 함수 입니다.

컨택스트를 소비하는 방식도 바뀐 이터페이스에 맞게 수정합니다.

function useContext(context) {
  // 컨택스트에서 값을 조회한다.
  // 에미터가 없으면 기본값을 제공할 것이다. 기존에는 에미터에 직접 접근해서 호출했는데 차이다.
  const [value, setValue] = React.useState(context.getValue());

  React.useEffect(() => {
    // 에미터가 아니라 on을 사용했다.
    context.on(setValue);

    return () => {
      // 에미터가 아니라 off를 사용했다.
      context.off(setValue);
    };
  }, [context]);

  return value;
}

컨택스트의 getValue() 함수로 값을 조회했습니다. 컨택스트에 이벤트 에미터가 없으면, 다시 말해 컨택스트 제공자를 찾지 못하면 기본값을 반환할 것입니다.

변경사항: 079fff7

문제 해결 확인

문제를 수정했으니 다시 테스트를 실행해 보겠습니다. npm test --wordspace 2-hook

PASS  test/MyReact.test.jsx
  useContext
    ✓ 컴포넌트가 한 번만 렌더 된다. (19 ms)

Test Suites: 1 passed, 1 total
Tests:       1 passed, 1 total
Snapshots:   0 total
Time:        0.811 s, estimated 1 s
Ran all test suites.

테스트를 통과했습니다.

브라우져도 확인해 보면 로그를 한번만 찍습니다.

Count Object { count: 0, setCount: setCount(nextValue) }

컨택스트 소비자를 사용하는 Count 컴포넌트를 한 번만 렌더했습니다. 불필요한 렌더링 동작을 제거했습니다.

결론

기존에 구현한 MyReact.createContext()가 미흡했습니다. 인자를 initialValue로 받았기 때문입니다. 이 값으로 이벤트 에미터를 만들어 컨택스트가 맨 처음 제공합니다. Provider가 props.value로 값을 받으면 이를 컨택스트로 다시 제공합니다. 값이 두 번 바뀌기 때문에 소비하는 컴포넌트에서는 두 번 렌더하는 현상이었습니다.

리액트 문서를 보고 문제를 고쳤습니다. createContext()의 인자는 기본값 역할입니다. 제공자를 찾지 못할 경우 마지막 값으로 사용합니다.

이벤트 에미터를 곧장 만들지 않습니다.대신 Provider가 생성합니다. Provider를 실수로 사용하지 않으면 이벤트 에미터는 없을 것입니다. 이 경우를 예방하기 위해 기본값을 사용하는 getValue()를 제공했습니다.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

1 participant