10 분 소요

14장 Context

  • Context 리액트 컴포넌트간에 어떠한 값을 공유할수 있게 해주는 기능 전역적(global)으로 필요한 값을 다룰 때 사용 (꼭 전역적일 필요는 없음) 컴포넌트간에 값을 전달하는 (props가 아닌) 또 다른 방법 깊숙히 위치한 컴포넌트에 데이터를 전달해야 하는 경우에는 여러 컴포넌트를 거쳐 연달아서 Props를 설정해주어야 하기 때문에 불편하고 실수할 가능성이 높아짐. 여러단계를 거친(4단계 이상?) 데이터 전달 => Props Drilling => 여러단계 불편 => 어디서 오는 값인지 파악하기 위해 거슬러 올라가는 것이 불편 => 네이밍을 value에서 msg로 변경할시 통일성을 맞추기 위해서 또 여러 컴포넌트들을 수정하는 것이 불편

컨텍스트란 무엇인가?

여러 컴포넌트에 걸쳐 자주 사용되는 데이터는 컨텍스트를 사용하면 편리하다. 컨텍스트늘 리액트 컴포넌트들 사이에서 데이터를 기존의 props를 통해 전달하는 방식 대신 컴포넌트 트리(component tree)를 통해 곧바로 컴포넌트에 전달하는 새로운 방식을 제공한다.

image-20240820095439652

위 그림은 상위 컴포넌트에서 하위 컴포넌트로 데이터를 전달하는 일반적인 방식이다.

하지만 로그인 여부, 프로필 정보 등 여러 컴포넌트에 걸쳐서 자주 사용되는 데이터를 전달하려면 반복적인 코드가 많이 생기고 지저분해진다는 단점이 있다.

이 단점을 해결하기 위해 등장한 것이 컨텍스트이다.

image-20240820095757742

컨텍스트를 사용하면 일일이 props로 전달할 필요 없이 위 그림처럼 데이터를 필요로 하는 컴포넌트에 곧바로 데이터를 전달 할 수 있다.

코드도 매우 깔끔해지고 데이터를 한 곳에서 관리하기 때문에 디버깅을 하기도 유리하다.

언제 컨텍스를 사용해야 할까?

여러 컴포넌트에서 자주 필요로 하는 데이터로는 사용자의 로그인 여부, 로그인 정보, UI 테마, 현재 선택된 언어 등이 있다.

이러한 데이터들을 기존 방식대로 컴포넌트의 props를 통해 넘겨주게되면 자식 컴포넌트의 자식 컴포넌트까지 계속해서 내려갈 수밖에 없다.

코드의 예시를 보자면 이러하다.

function App(props){
    return <Toolbar theme="dark"/>;
}

function Toolbar(props){
    //이 Toolbar 컴포넌트는 ThemeButton에 theme를 넘겨주기 위해서 'theme' props를 가져야만 한다.
    //현재 테마를 알아야 하는 모든 버튼에 대해서 props로 전달하는 것은 굉장히 비효율적이다.
    return(
    	<div>
        	<ThemedButton theme={props.theme}/>
        </div>
    );
}

function ThemedButton(props) {
    return <Button theme={props.theme}/>;
}

위 코드는 총 3개의 컴포넌트가 나온다.

먼저 가장 상위 컴포넌트인 App 컴포넌트에서는 Toolbar 컴포넌트를 사용한다. 이때 theme이라는 이름의 prop으로 현재 테마인 dark를 넘기고 ThemeButton 컴포넌트에 theme를 전달한다.

➡️ ThemeButton 컴포넌트에서는 props.theme로 데이터에 접근하여 버튼에 어두운 테마가 된다.

이렇게 props를 통해서 데이터를 전달하는 기존 방식은 실제 데이터를 필요로 하는 컴포넌트까지의 깊이가 깊어질수록 복잡해진다.

또한 코드의 반복으로 비효율적이고 직관적이지 않다.

이제 위의 코드를 컨텍스트를 사용해서 같은 기능을 구현해보겠습니다.

//컨텍스트는 데이터를 매번 컴포넌트를 통해 전달할 필요 없이
//컴포넌트 트리로 곧바로 전달하게 해준다.
//여기에서는 현재 테마를 위한 컴텍스트를 생성하며, 기본값은 'light'이다.
const ThemeContext = React.createContext("light");

//Provider를 사용하야 하위 컴포넌트들에게 현재 테마 데이터를 전달한다.
// 모든 하위 컴포넌트들은 컴포넌트 트리 하단에 얼마나 깊이 있는지에 관계 없이 데이터를 읽을 수 있다.
//여기에서는 현재 테마 값으로 'dark'를 전달하고 있다.
function App(props) {
  return (
    <ThemeContext.Provider value="dark">
      <Toolbar />
    </ThemeContext.Provider>
  );
}

//이제 중간에 위치한 컴포넌트는
//테마 데이터를 하위 컴포넌트로 전달 할 필요가 없습니다.
function Toolbar(props) {
  return (
    <div>
      <ThemedButton />
    </div>
  );
}

function ThemedButton(props) {
  //리액트는 가장 가까운 상위 테마 Provider를 차아서 해당되는 값을 사용합니ㅏㄷ.
  //만약 해당되는 Provider가 없을 경우 기본값(여기서는 'light')을 사용합니다.
  //여기에서는 상위 Provider가 있기 때문에 현재 테마의 값은 'dark'가 됩니다.
  return (
    <ThemeContext.Consumer>
      {(value) => <Button theme={values} />}
    </ThemeContext.Consumer>
  );
}

React.createContext()함수를 사용해서 ThemeContext라는 이름의 컨텍스트를 하나 생성했다.

컨텍스트를 사용할 컴포넌트의 상위 컴포넌트에서 Provider로 감싸주어야 하는데 여기서는 최상위 컴포넌트인 App 컴포넌트를 ThemeContext.Provider로 감싸주었다.

이렇게 하면 Provider의 모든 하위 컴포넌트가 얼마나 깊이 위치해있는지 관계 없이 컨텍스트의 데이터를 읽을 수 있다.

이처럼 여러 컴포넌트에서 계속 접근이 일어날 수 있는 데이터들이 있는 경우에 컨텍스트를 사용하는 것이 좋다.

컨텍스트를 사용하기 전에 고려할 점

컨텍스트는 다른 레벨의 많은 컴폰너트가 특정 데이터를 필요로 하는 경우에 주로 사용하는데 무조건 컨텍스트를 사용하는 것은 좋은 것이 아니다.

➡️ 컴포넌트와 컨텍스트가 연동되면 재사용성이 떨어진다. 그래서 다른 레벨의 많은 컴포넌트가 데이터를 필요로 하는 경우가 아니라면 기존에 사용하던 방식대로 props를 통해 데이터를 전달하는 컴포넌트 합성 방법이 더 적합하다.

//page컴포넌트는 PageLayout 컴포넌트를 렌더링
<Page user={user} avatarSize={avatarSize}/>

//PageLayout 컴포넌트는 NavigationBar 컴포넌트를 렌더링
<PageLayout user={user} avatarSize ={avatarSize}/>

//NavigationBar컴포넌트는 Link컴포넌트를 렌더링
<NavigationBar user={user} avatarSize={avatarSize}/>

//Link컴포넌트 Avatar 컴포넌트를 렌더링
<Link href={user.permalink}>
	<Avatar user={user} size={avatarSize}/>
</Link>

위의 코드에는 사용자 정보와 아바타 사이즈를 몇 단계에 걸쳐서 하위 컴포넌트인 Link와 Avatar로 전달하는 Page컴포넌트가 있다. 여기서 가장 하위 레벨에 위치한 Avatar 컴포넌트가 user와 avatarSize를 필요로 하기 때문에, 이를 위해 여러 단계에 걸쳐서 props를 통해 user와 avatarSize를 전달해 주고 있습니다.

추가적인 데이터가 필요하면 해당 데이터도 추가로 여러 단계에 걸쳐서 넘겨주어야 하기 때문에 번거롭다.

➡️ 해결법: Avatar 컴포넌트를 변수에 저장하여 직접 넘겨준다.

function Page(props){
    const user = props.user;
    
    const userLink=(
    <Link href = {user.permalink}>
    	<Avatar user={user} size={props.avatarSize}/>
   	</Link>
    );
    //Page 컴포넌트는 PageLayout 컴포넌트를 렌더링
    //이 때 props로 userLink를 함께 전달함.
    return <PageLayout userLink={userLink}/>;
}
//PageLayout 컴포넌트는 NavigationBar 컴포넌트를 렌더린
<PageLayout userLink={...}/>

//NavigationBar 컴포넌트는 props로 전달밥은 userLink element를 리턴
<NavigationBar userLink={...}/>

위 코드는 user 와 avatarSize가 props로 들어간 Avatar 컴포넌트를 userLink라는 변수에 저장한 뒤에 해당 변수를 하위 컴포넌트로 넘기고 있다.

이렇게 하면 가장 상위 레벨에 있는 Page 컴포넌트만 Avatar 컴포넌트에서 필요로 하는 user와 avatarSize에 대해 알고 있으면된다.

이 방법 또한 모든 경우에 유용한 것이 아니다. 데이터가 많아질 수록 상위 컴포넌트에 몰리기 때문에 상위 컴포넌트는 더 복잡해지고 하위는 느슨해진다.

그래서 하위 컴포넌트를 여러 개의 변수로 나눠서 전달하는 방법도 있다.

function Page(props){
    const user = props.user;
    
    const topBar = (
    <NavigationBar>
    	<Link href={user.permalink}>
        	<Avatar user={user} size={props.avatarSize}/>
        </Link>
    </NavigationBar>
    );
    const content = <Feed user={user}/>;
    
    return (
    	<PageLayout
            topBar={topBar}
            content={content}
        />
    );
}

이 방식은 하위 컴포넌트의 의존성을 상위 컴포넌트와 분리할 필요가 잇는 대부분의 경우에 적합한 방식이다.

또한 렌더링 전에 하위 컴포넌트가 상위 컴포넌트와 통신해야하는 경우 render props를 사용해 처리할 수 있다.

컨텍스트 API

리액트에서 제공하는 컨텍스트 API를 사용해서 컨텍스트를 어떻게 사용하는지 알아보겠다.

React.createContext

컨택스트를 사용하기 위해 제일 먼저 컨텍스트를 생성해 줘야한다.

React.createContext()함수를 사용해서 함수의 파라미터로 기본값을 넣어주면 된다.

const MyContext = React.createContext(기본값);

리액트에서 렌더링이 일어날 때 컨텍스트 객체를 구독하는 하위 컴포넌트가 나오면 현재 컨텍스트의 값을 가장 가까이에 있는 상위 레벨의 Provider로 부터 받아오게 된다.

상위 레벨에 매칭되는 Provider가 없다면 기본값이 사용된다.

기본 값으로 undefined를 넣으면 기본값이 사용되지 않는다.

Context.Provider

컨텍스트를 생성하고 이제 하위 컴포넌트들이 해당 컨텍스트의 데이터를 받을 수 있도록 설정해 줘야한다. ➡️ Provider데이터를 제공해주는 컴포넌트

모든 컨텍스트 객체는 Provider라는 리액트 컴포넌트를 가지고 있다.

Context.Provider 컴포넌트로 하위 컴포넌트들을 감싸주면 모든 하위 컴포넌트들이 해당 컨텍스트의 데이터에 접근 할수 있다.

<MyContext.Provider value={/* some value*/}>

Provider 컴포넌트에는 value라는 prop이 있으며, 이는 Provider 컴포넌트 하위에 있는 컴포넌트 들에게 전달된다.

  • consumer 컴포넌트 : 하위 컴포넌트가 Provider의 value를 소비한다는 의미에서 붙여짐

Class.contextType

Provider 하위에 있는 클래스 컴포넌트에서 컨텍스트의 데이터에 접근하기 위해 사용하는 것이다.

MyClass.contextType = MyContext;라고 하면 MyClass라는 클래스 컴포넌트는 MyContext의 데이터에 접근가능 하다.

클래스 컴포넌트 안에있는 contextType의 속성에는 React.createContext() 함수를 통해 생성된 컨텍스트 객체가 대입될 수 있다.

this.context를 통해 상위에 나와있는 Provider중에서 가장 가까운 것의 값을 가져올 수 있다.

이 API는 단 하나의 컨텍스트만 구독한다.

Context.Consumer

컨텍스트의 데이터를 구독하는 컴포넌트이다.

함수 컴포넌트에서는 Context.Consumer를 사용해서 컨텍스트를 구독할 수 있다.

<MyContext.Consumer>
	{value => /*컨텍스트의 값에 따라서 컴포넌트들을 렌더링*/}
</MyContext.Consumer>
  • function as a child

    컴포넌트의 자식으로 함수가 온 것

Context.Consumer로 감싸주면 자식으로 들어간 함수가 현재 컨텍스의 value를 받아서 리액트 노드로 리턴하게 된다.

상위 컴포넌트에 Provider가 없으면 value파라미터는 createContext()를 호출할 때 넣는 기본값과 동일한 역할을 한다.

Context.displayName

컨텍스트 객체는 displayName이라는 문자열 속성을 가진다.

const MyContext = React.createContext(/* some value*/);
MyContext.disPlayName = 'MyDisplayName';

//개발자 도구에 "MyDisplayName.Provider"로 표시됨
<MyContext.Provider/>

//개발자 도구에 "MyDisplayName.Consumer"로 표시됨
<MyContext.Consumer/>

여러개의 컨텍스트 사용하기

여러 개의 컨텍스트를 동시에 사용하려면 Context.Provider를 중첩해서 사용하는 방식으로 구현하면 된다.

import React from "react";

//테마를 위한 컨텍스트
const ThemeContext = Reacat.createContext("light");

//로그인 한 사용자를 위한 컨텍스트
const UserContext = React.createContext({
  name: "Guset",
});

class App extends React.Component {
  render() {
    const { signedInUser, theme } = this.props;
    return (
      <ThemeContext.Provider value={theme}>
        <UserContext.Provider value={signedInUser}>
          <Layout />
        </UserContext.Provider>
      </ThemeContext.Provider>
    );
  }
}
function Layout() {
  return (
    <div>
      <Sidebar />
      <Content />
    </div>
  );
}

//컨텍스트 컴포넌트 두 개의 컨테그스로부터 값을 가져와서 렌더링함
function Content() {
  return (
    <ThemeContext.Consumer>
      {(theme) => (
        <UserContext.Consumer>
          {(user) => <ProfilePage user={user} theme={theme} />}
        </UserContext.Consumer>
      )}
    </ThemeContext.Consumer>
  );
}

여기서 ThemeContext와 UserContext 두 개의 컨텍스트가 나오는데 APP컴포넌트에서는 각 컨텍스트에 대해 두 개의 Provider를 사용하여 자식 컴포넌트인 Layout을 감싸주었다.

그리고 실제 컨텍스트의 데이터를 사용하는 Content컴포넌트에서는 두개의 Consumer 컨폰너트를 사용해서 데이터를 전달하고 있다.

useContext

함수 컨포넌트에서 컨텍스트를 사용하기 위해 컴포넌트를 매번 Consumer 컴포넌트로 감싸주는 것 보다 더 좋은 방법이 있다.

➡️ useContext()훅을 사용하는 것이다.

function MyConponent(props){
    const value = useContext(MyContext);
    
    return(
    	...
    );
}

useContext() 훅을 사용하면 다른 방식과 동일하게 컴포넌트를 트리사에서 가장가까운 상위 Provider로부터 컨텍스트의 값을 받아오게 된다.

만약 컨텍스트의 값이 변경되면 변경된 값과 함께 useContext() 훅을 사용하는 컴포넌트가 재렌더링 된다.

💡기억하기

useContext()를 사용할 때에는 파리미터로 컨텍스트 객체를 넣어줘야 한다.

//올바른 사용법
useContext(MyContext);
//잘못된 방법
useContext(MyContext.Provider);
useContext(Mycontext.Consumer);

실습

image-20240820112209306image-20240820112219797

계산기 만들기

image-20240820184927417

해당 화면을 오늘 배운 Context와 useMemo를 활용해서 구현해보자!

  import { useContext, useState, useMemo, createContext } from "react";

  const CalcContext = createContext();
  const ButtonActionContext = createContext();

  function CalcProvider({ children }) {
    const [calc, setCalc] = useState(null);
    const [num1, setNum1] = useState("");
    const [num2, setNum2] = useState("");
    const [operator, setOperator] = useState("");

    const resetInputs = () => {
      setNum1("");
      setNum2("");
      setOperator("");
    };

    const calculate = () => {
      let result = null;
      //  setCalc(result);
      let cal;
      // 연산자에 따라 계산 수행
      switch (operator) {
        case "+":
          cal = parseFloat(num1) + parseFloat(num2);
          result = `${num1} + ${num2} = ${parseInt(num1) + parseInt(num2)}`;
          break;
        case "-":
          cal = parseFloat(num1) - parseFloat(num2);
          result = `${num1} - ${num2} = ${parseInt(num1) - parseInt(num2)}`;
          break;
        case "/":
          cal = parseFloat(num1) / parseFloat(num2);
          result = `${num1} / ${num2} = ${cal}`;
          break;
        case "*":
          cal = parseFloat(num1) * parseFloat(num2);
          result = `${num1} * ${num2} = ${cal}`;
          break;
        default:
          return; // 연산자가 선택되지 않은 경우 아무 작업도 하지 않음
      }
      setCalc(result); // 계산된 결과를 저장
      resetInputs(); // 입력 필드와 선택된 연산자 초기화
    };

    const actions = useMemo(
      () => ({
        setNum1: (value) => {
          setNum1(value);
          setCalc(null); // 새로운 입력이 발생하면 결과 초기화
        },
        setNum2: (value) => {
          setNum2(value);
          setCalc(null); // 새로운 입력이 발생하면 결과 초기화
        },
        setOperator: (value) => {
          setOperator(value);
          setCalc(null); // 새로운 입력이 발생하면 결과 초기화
        },
        calculate,
      }),
      [num1, num2, operator]
    );// 오늘의 배운것 num1, num2, operator를 넘겨줘야한다. 처음에 빈 것으로([]) 뒀더니 초기 결과 값이 저장되고 바뀌지 않았다.!

    return (
   
        <ButtonActionContext.Provider value={actions}>
          {children}
        </ButtonActionContext.Provider>
      </CalcContext.Provider>
    );
  }

  function CalcHeader() {
    return (
      <div>
        <h1>초간단 계산기</h1>
      </div>
    );
  }

  function CalcBody() {
    const { num1, num2, operator } = useContext(CalcContext);
    const { setNum1, setNum2, setOperator, calculate } =
      useContext(ButtonActionContext);
    return (
      <div>
        <input
          type="number"
          value={num1}
          onChange={(e) => setNum1(e.target.value)}
        />
        <select value={operator} onChange={(e) => setOperator(e.target.value)}>
          <option value="">선택</option>
          <option value="+">+</option>
          <option value="-">-</option>
          <option value="/">/</option>
          <option value="*">*</option>
        </select>
        <input
          type="number"
          value={num2}
          onChange={(e) => setNum2(e.target.value)}
        />
        <button onClick={calculate}>계산</button>
      </div>
    );
  }

  function CalcResult() {
    const { calc } = useContext(CalcContext);
    console.log(calc);
    return (
      <div>
        결과:
        {/* {calc !== null && <span>{calc}</span>} */}
        <span>{calc}</span>
      </div>
    );
  }

  function MyCalc() {
    return (
      <CalcProvider>
        <CalcHeader />
        <CalcBody />
        <CalcResult />
      </CalcProvider>
    );
  }

  export default MyCalc;

이렇게 완성했다.

문제 발생

처음에 값이 입력되고 결과 값이 결정되고 다른 값이 입력됐을때 새로운 결과를 받아야하는데, 갱신되지 않았다.

import { useContext, useState, useMemo, createContext } from "react";

const CalcContext = createContext();
const ButtonActionContext = createContext();

function CalcProvider({ children }) {
  const [calc, setCalc] = useState(null);
  const [num1, setNum1] = useState("");
  const [num2, setNum2] = useState("");
  const [operator, setOperator] = useState("");

  const resetInputs = () => {
    setNum1("");
    setNum2("");
    setOperator("");
  };

  const calculate = () => {
    let result = null;
    //  setCalc(result);
    let cal;
    // 연산자에 따라 계산 수행
    switch (operator) {
      case "+":
        cal = parseFloat(num1) + parseFloat(num2);
        result = `${num1} + ${num2} = ${parseInt(num1) + parseInt(num2)}`;
        break;
      case "-":
        cal = parseFloat(num1) - parseFloat(num2);
        result = `${num1} - ${num2} = ${parseInt(num1) - parseInt(num2)}`;
        break;
      case "/":
        cal = parseFloat(num1) / parseFloat(num2);
        result = `${num1} / ${num2} = ${cal}`;
        break;
      case "*":
        cal = parseFloat(num1) * parseFloat(num2);
        result = `${num1} * ${num2} = ${cal}`;
        break;
      default:
        return; // 연산자가 선택되지 않은 경우 아무 작업도 하지 않음
    }
    setCalc(result); // 계산된 결과를 저장
    resetInputs(); // 입력 필드와 선택된 연산자 초기화
  };

  const actions = useMemo(
    () => ({
      setNum1: (value) => {
        setNum1(value);
        setCalc(null); // 새로운 입력이 발생하면 결과 초기화
      },
      setNum2: (value) => {
        setNum2(value);
        setCalc(null); // 새로운 입력이 발생하면 결과 초기화
      },
      setOperator: (value) => {
        setOperator(value);
        setCalc(null); // 새로운 입력이 발생하면 결과 초기화
      },
      calculate,
    }),
    []
  );// 오늘의 배운것 num1, num2, operator를 넘겨줘야한다. 처음에 빈 것으로([]) 뒀더니 초기 결과 값이 저장되고 바뀌지 않았다.!

  return (
    const contextValue = { calc, num1, num2, operator };
      <CalcContext.Provider value={contextValue}>
      <ButtonActionContext.Provider value={actions}>
        {children}
      </ButtonActionContext.Provider>
    </CalcContext.Provider>
  );
}

function CalcHeader() {
  return (
    <div>
      <h1>초간단 계산기</h1>
    </div>
  );
}

function CalcBody() {
  const { num1, num2, operator } = useContext(CalcContext);
  const { setNum1, setNum2, setOperator, calculate } =
    useContext(ButtonActionContext);
  return (
    <div>
      <input
        type="number"
        value={num1}
        onChange={(e) => setNum1(e.target.value)}
      />
      <select value={operator} onChange={(e) => setOperator(e.target.value)}>
        <option value="">선택</option>
        <option value="+">+</option>
        <option value="-">-</option>
        <option value="/">/</option>
        <option value="*">*</option>
      </select>
      <input
        type="number"
        value={num2}
        onChange={(e) => setNum2(e.target.value)}
      />
      <button onClick={calculate}>계산</button>
    </div>
  );
}

function CalcResult() {
  const { calc } = useContext(CalcContext);
  console.log(calc);
  return (
    <div>
      결과:
      {/* {calc !== null && <span>{calc}</span>} */}
      <span>{calc}</span>
    </div>
  );
}

function MyCalc() {
  return (
    <CalcProvider>
      <CalcHeader />
      <CalcBody />
      <CalcResult />
    </CalcProvider>
  );
}

export default MyCalc;

보니까 num1과 num2가 제대로 들어가지 않는 것 같았다.

이를 해결한 방법은

  const actions = useMemo(
    () => ({
      setNum1: (value) => {
        setNum1(value);
        setCalc(null); // 새로운 입력이 발생하면 결과 초기화
      },
      setNum2: (value) => {
        setNum2(value);
        setCalc(null); // 새로운 입력이 발생하면 결과 초기화
      },
      setOperator: (value) => {
        setOperator(value);
        setCalc(null); // 새로운 입력이 발생하면 결과 초기화
      },
      calculate,
    }),
    []
  );

여기서 []에 넘길 값을 넣어 줘야 했다.

...    
}),
    [num1, num2, operator]
  );

업데이트:

댓글남기기