[React Context API] 앱수준/컴포넌트 수준에서 state 관리하기

summereuna🐥·2023년 4월 26일
0

React JS

목록 보기
46/69

Managaing App-Wide or Component-Wide State with Context

큰 리액트 앱에서 마주칠 수 있는 문제에 대해 알아보자.

문제: 프롭을 통해 많은 컴포넌트를 거쳐 많은 데이터를 전달할 때 일어나는 문제

일반적으로 데이터는 프롭을 통해 컴포넌트에 전달되는데, state를 여러 컴포넌트를 통해 전달할 경우 문제가 생길 수 있다.

  • App에서 생성된 isLoggedIn 데이터 > MainHeader 컴포넌트에 전달
  • MainHeader에서는 사용하지 않고 > Navigation 컴포넌트에 전달

isLoggedIn 데이터가 실제로 필요한 곳은 Navigation 컴포넌트이기 때문에 이런식으로 아래로아래로 전달하고 있다. 그러면 중간에 prop을 단지 전달만 하는 컴포넌트들이 생긴다. 즉, 데이터를 필요로 하는 다른 컴포넌트에 직접적으로 연결되지 않는다. 앱이 커진다면 전달하는 경로가 점점 더 길어질 수 있다.
따라서 데이터를 컴포넌트를 통해 다른 컴포넌트에 전달하려는 큰 프롭 체인이 만들어 질 수 있다. 이런 현상이 딱히 나쁜 건 아니지만 앱이 커질수록 불편해진다.

prop을 실제로 필요한 데이터를 부모로부터 받는 컴포넌트에서만 사용할 수 있게 하면 훨씬 간편하다. 부모가 데이터를 관리하지도 필요하지도 않으므로 부모를 통해 데이터를 전달하지 않도록 말이다.

이를 위해 컴포넌트 전체에서 사용할 수 있는, 리액트에 내장된 내부 state 저장소가 있는데 바로 React Context이다. 이를 사용하면, 긴 프롭 체인을 만들지 않고도, 컴포넌트 전체 state저장소에서 액션을 트리거하여 관련된 컴포넌트에 직접 전달할 수 있다.

📝 Context API란?


리액트 내부적으로 state를 관리할 수 있게 해준다.
프롭 체인을 구축하지 않아도, 앱의 어떤 컴포넌트에서도 state를 직접 변경하여, 앱의 다른 컴포넌트에 state를 직접 전달할 수 있게 해준다.

✅ Context 만들기


1. src/store 폴더 추가

store (또는 state 나 context) 폴더를 추가한다.

2. 인증 state들을 관리하기 위한 파일 추가

파일 이름은 마음대로 하면 되는데, AuthContext.js처럼 파스칼 표기법으로 표시하면 여기에 컴포넌트를 저장한다는 뜻이 되어버리므로, auth-context.js같이 소문자 케밥 표기법을 사용하여 이름을 정하자.

앱의 여러개의 전역 state에 대해 여러 개의 컨텍스트를 가질 수 있다. 더 큰 state에 대해 하나의 컨텍스트만 사용할 수도 있고, 그냥 자기 마음이다. 여기서는 일단 컨텍스트가 어떻게 작동되는지 살펴보자.

3. 리액트를 임포트 한 후, 컨텍스트 객체 생성하기 위해 리액트에서 createContext 호출

createContext()기본(default) 컨텍스트를 만든다.

import React from "react";

React.createContext();

컨텍스트는 앱이나 빈 state의 컴포넌트일 뿐이므로 state가 어때야 하는지는 본인이 정하면된다.

  • 앱이나 컴포넌트 수준에서 state는 그냥 텍스트일 수도 있다.
    기본 state로 그냥 텍스트를 써도 된다.

    React.createContext("my state");
  • 하지만 대부분의 경우 객체이다.
    이 경우에는 isLoggedIn의 state를 관리하므로 기본 값으로 { isLoggedIn: false }를 주면 된다.

    React.createContext({
      isLoggedIn: false,
    });

4. 컨텍스트 이름 정하고 내보내기

createContext()에서 얻는 결과는 컴포넌트 혹은, 컴포넌트를 포함하는 객체가 된다. 따라서 이름을 AuthContext로 정하자. AuthContext 자체가 컴포넌트는 아니지만, 컴포넌트를 포함할 객체이기 때문이다.

다른 곳에서 이 컨텍스트 객체를 사용해야 하기 때문에 export default로 내보낸다.

import React from "react";

const AuthContext = React.createContext({
  isLoggedIn: false,
});

export default AuthContext;

✅ Context 사용하기


✅ 1. 공급하기(Providing): 리액트에게 컨텍스트가 있다고 알리기(JSX 코드로 감싸기)

  • 공급한다는 것은 JSX 코드로 감싸는 것을 뜻하는데, 감싸는 모든 컴포넌트는 컨텍스트에 접근 권한이 생긴다.

  • 컨텍스트를 활용해야 하는 모든 컴포넌트를 JSX 코드로 감싸면 해당 컨텍스트를 리스닝할 수 있게 된다.
    예)

    • 이 컨텍스트가 앱의 모든 곳에 필요하다면, 앱 컴포넌트에 있는 모든 컴포넌트를 감싸면 된다.
    • 이 컨텍스트가 로그인 컴포넌트와 그 자식 컴포넌트에서만 필요하다면, 로그인 컴포넌트만 감싸면 된다.

📍 src/App.js

모든 컴포넌트에 isLoggedIn이 필요하기 때문에 <AuthContext.Provider>로 모든 컴포넌트를 감싸준다. 이 컨텍스트를 wrapper 컴포넌트처럼 활용했기 때문에 기존에 사용하던 <></> 리액트 프래그먼트는 삭제해도 된다.

//...
import AuthContext from "./store/auth-context";

//...
return (
  //<>
    <AuthContext.Provider>
      <MainHeader isAuthenticated={isLoggedIn} onLogout={logoutHandler} />
      <main>
        {!isLoggedIn && <Login onLogin={loginHandler} />}
        {isLoggedIn && <Home onLogout={logoutHandler} />}
      </main>
    </AuthContext.Provider>
  //</>
  );

이렇게 하면 MainHeader, Login, Home 등 모든 자식 컴포넌트에서 AuthContext에 접근할 수 있다.

✅ 2. 소비하기(Consume it): 연동하기/리스닝하기

컨텍스트 값에 접근하려면 리스닝해야 한다.
리스닝하는데는 두 가지 방법이 있다.
1. 컨텍스트 소비자<Context.Consumer></Context.Consumer>로 리스닝하기
2. useContext() 훅 사용하여 리스닝하기
일반적으로는 리액트 훅을 사용하여 리스닝하지만, 소비자로 리스닝 하는 방법도 알아보자.

🌀 1. <AuthContext.Consumer> 소비자로 리스닝하기

Navigation 에서 사용자가 인증되었는지 여부를 알고 싶다고 해보자.
이를 위해 AuthContext를 사용할 수 있다.

  • 데이터가 필요한 모든 것을 소비자로 감싸준다.
<AuthContext.Consumer>
//..
</AuthContext.Consumer>
  • 소비자는 함수() => {}여야 한다.
  • 인수컨텍스트 데이터(ctx) => {}를 가져오면 객체{ isLoggedIn: false }를 얻게된다.
  • 소비자는 JSX 코드반환(return) 해야 한다.
    return 값에 Navigation 컴포넌트가 반환하는 JSX 코드를 넣으면 된다.
    그러면 JSX 코드에서 props.isLoggedIn 대신 ctx.isLoggedIn으로 컨텍스트에 접근할 수 있다.

📍 src/components/MainHeader/Navigation.js

import React from "react";

import AuthContext from "../../store/auth-context";
import classes from "./Navigation.module.css";

const Navigation = (props) => {
  return (
    //✅ AuthContext 소비자로 감싸기
    <AuthContext.Consumer>
      //✅ 소비자는 함수, 인수로 AuthContext 객체를 보냄
      {(ctx) => {
        //✅ 함수의 리턴 값으로 Nav의 JSX 반환
        return (
          <nav className={classes.nav}>
            <ul>
              //✅ AuthContext 객체 안의 isLoggedIn 값 사용
              {ctx.isLoggedIn && (
                <li>
                  <a href="/">Users</a>
                </li>
              )}
              {ctx.isLoggedIn && (
                <li>
                  <a href="/">Admin</a>
                </li>
              )}
              {ctx.isLoggedIn && (
                <li>
                  <button onClick={ctx.onLogout}>Logout</button>
                </li>
              )}
            </ul>
          </nav>
        );
      }}
      //✅ AuthContext 소비자로 감싸기
    </AuthContext.Consumer>
  );
};

export default Navigation;

⚠️ 하지만 위 코드를 저장하면 코드 충돌 오류가 뜬다.

왜냐하면 컨텍스트의 기본값은 실제로, 공급자 없이 소비하는 경우에만 사용되기 때문이다. 엄밀히 말하면, 컨텍스트에 디폴트값이 있으면 공급자는 필요하지 않다.

하지만 이 패턴은 기억해 두자! 변할 수 있는 값을 가지기 위해 컨텍스트를 사용하는데 이는 공급자로만 가능하다.

📍 src/App.js

//...
  return (
    <AuthContext.Provider value={{ isLoggedIn: false }}>
      <MainHeader isAuthenticated={isLoggedIn} onLogout={logoutHandler} />
      <main>
        {!isLoggedIn && <Login onLogin={loginHandler} />}
        {isLoggedIn && <Home onLogout={logoutHandler} />}
      </main>
    </AuthContext.Provider>
  );
}

✅ 충돌을 피하기 위해서는 공급자 컴포넌트에 value 프롭을 추가해야 한다.
내가 만든 컴포넌트가 아니기 때문에 value에 컨텍스트 객체를 전달하면 된다.
<AuthContext.Provider value={{ isLoggedIn: false }}>

그러면 해당 객체를 변경할 수 있게 된다. state나 앱 컴포넌트를 통해서 값이 변경될 때 마다 새로운 state값이 모든 소비 컴포넌트에 전달된다.

하지만 컨텍스트의 데이터를 하드 코딩하여 가져오는 대신 app.js에서 관리하고 있는 isLoggedIn state를 컨텍스트 객체에 전달하여 가져와 보자.

📍 src/App.js

//...
  return (
    <AuthContext.Provider value={{ isLoggedIn: isLoggedIn }}>
      <MainHeader isAuthenticated={isLoggedIn} onLogout={logoutHandler} />
      <main>
        {!isLoggedIn && <Login onLogin={loginHandler} />}
        {isLoggedIn && <Home onLogout={logoutHandler} />}
      </main>
    </AuthContext.Provider>
  );
}

✅ 앱 컴포넌트에서 공급자를 설정할 때, <AuthContext.Provider value={{ isLoggedIn: isLoggedIn }}>라고 설정하면 된다.
앱 컴포넌트에서 isLoggedIn state를 관리하는데, 공급자가 전달하는 값에 isLoggedIn을 false로 하드 코딩 하는 대신 state값인 isLoggedIn 을 전달하면 value 객체는 isLoggedIn state가 변경될 때마다 리액트에 의해 업데이트 된다. 그리고 그 새로운 컨텍스트 객체는 모든 리스닝 컴포넌트로 전달된다.

📍 src/App.js

//...
  return (
    <AuthContext.Provider value={{ isLoggedIn: isLoggedIn }}>
      <MainHeader onLogout={logoutHandler} />
      <main>
        {!isLoggedIn && <Login onLogin={loginHandler} />}
        {isLoggedIn && <Home onLogout={logoutHandler} />}
      </main>
    </AuthContext.Provider>
  );
}

✅ 이처럼 컨텍스트를 사용하면 state를 전달하기 위해 프롭을 사용할 필요가 없어진다.
대신 공급자에 value 프롭을 설정하면된다. 그러면 모든 자식 컴포넌트에서 이 값을 리스닝할 수 있다.

isLoggedIn을 프롭으로 전달하는 부분들은 삭제하면 된다.

소비자는 컨텍스트를 소비하는 한 가지 방법으로 나름 괜찮은 방법이지만 함수도 있고, 코드도 반환하고.. 좀 복잡스럽다 ^^~ 컨텍스트 훅을 사용하여 리스닝하는 방법을 알아보자.

✅ 2. useContext() 훅으로 컨텍스트에 태핑하기(tapping)

  • useContext() 훅은 컨텍스트 사용, 활용, 리스닝 할 수 있게 하는 훅이다.
    리액트 컴포넌트 함수에서 useContext()호출하여 사용하면 된다.
//✅
import React, { useContext } from "react";

const Navigation = (props) => {
  //✅
  useContext();
  
  return (
    //...
  • 사용하려는 컨텍스트를 임포트하고, useContext() 훅에 사용하려는 컨텍스트를 가리키는 포인터를 전달하면 컨텍스트 값을 얻을 수 있다.
import React, { useContext } from "react";
import AuthContext from "../../store/auth-context";

const Navigation = (props) => {
  //✅
  useContext(AuthContext);
  
  return (
    //...
  • 가져온 컨텍스트 값을 상수로 저장한 후 JSX에 사용하면 된다.
import React, { useContext } from "react";

import AuthContext from "../../store/auth-context";
import classes from "./Navigation.module.css";

const Navigation = (props) => {
  //✅
  const ctx = useContext(AuthContext);
  
  return (
    <nav className={classes.nav}>
      <ul>
        {ctx.isLoggedIn && (
          <li>
            <a href="/">Users</a>
          </li>
        )}
        {ctx.isLoggedIn && (
          <li>
            <a href="/">Admin</a>
          </li>
        )}
        {ctx.isLoggedIn && (
          <li>
            <button onClick={props.onLogout}>Logout</button>
          </li>
        )}
      </ul>
    </nav>
  );
};

export default Navigation;

isLoggedIn은 동적으로 전달하고 있지만 여전히 로그인 핸들러/로그아웃 핸들러는 컴포넌트의 prop으로 전달하고 있다. 컴포넌트 뿐만 아니라 함수에도 데이터를 전달하기 위해 동적 컨텍스트를 설정할 수 있다.

//MainHeader로는 context를 전달> 그 아래 컴포넌트인 Navigation 컴포넌트로 컨텍스트 전달
<AuthContext.Provider
  value={{
    isLoggedIn: isLoggedIn,
      //함수도 전달가능
      onLogout: logoutHandler,
  }}
  >
  <MainHeader />
  <main>
    {!isLoggedIn && <Login onLogin={loginHandler} />}
    {isLoggedIn && <Home onLogout={logoutHandler} />}
  </main>
</AuthContext.Provider>
//Login과 Home 컴포넌트로는 props으로 전달

📝 언제 props을 사용하고 언제 context를 사용해야 할까?


<MainHeader />context를 전달하여 그 아래 컴포넌트인 <Navigation /> 컴포넌트로 컨텍스트 전달하여 데이터를 사용하고 있다. 이처럼 바로 아래 컴포넌트가 데이터를 사용하지 않고 전달만 하는 경우에는 컨텍스트를 사용하면 좋다.

<Login />, <Home /> 컴포넌트로는 props을 통해 핸들러를 보내고 있다. 두 컴포넌트는 props을 직접 참조하기 때문에 컨텍스트 보다 props을 사용하는 것이 좋다.

  • props을 사용하면 좋은 경우
    대부분의 경우 props을 사용하여 컴포넌트에 데이터를 전달한다.
    바로 아래 컴포넌트에서 직접 props을 사용할 경우, 컨텍스트 보다 props을 사용하는 것이 좋다.
    프롭은 컴포넌틀르 구성하고 그것들을 재사용할 수 있도록 하는 매커니즘을 가졌기 때문이다.

    • 예) <Login />,<Home /> 컴포넌트에서는 데이터를 직접 사용하고 있음
    • 엄밀히 말하면 <Home />컴포넌트에서는 <Home />컴포넌트에 있는 <Button /> 컴포넌트에 propsonLogout으로 logoutHandler를 전달하고 있다. <Button /> 같은 프리젠테이션을 위한 컴포넌트의 경우, 버튼 클릭을 항상 onLogout에 바인딩하기 위해 버튼 내부에 굳이 컨텍스트를 사용하지는 않는다. 그렇게 하면 onLogout을 전달할 필요는 없어지지만 또한, 버튼이 클릭될 때마다 사용자를 로그아웃시키는 것 외의 다른 일을 할 수 없게 되기 때문이다. 따라서 props을 사용해도 충분하다.
  • 컨텍스트를 사용하면 좋은 경우

    • 많은 컴포넌트를 통과해 데이터를 전달한 후 데이터를 사용하고자 하는 경우
    • 네비게이션처럼 매우 특정적인 일을 하는 컴포넌트의 경우
    • 항상 사용자를 로그아웃시키는 특정적인 버튼 컴포넌트인 경우

[⚙️ 리팩토링] 독립 실행형 파일 만들기: off state 관리 및 context 관리


앱 구조나 데이터를 관리하는 방법이나 선호에 따라 <App /> 컴포넌트에 있는 많은 로직을 하나의 파일로 옮겨서 깔끔하게 관리하고 싶을 수도 있다. 별도의 state 및 컨텍스트 관리 컴포넌트를 만들고 싶은 경우, 아래 처럼 파일 하나에 몰빵하면 집중적으로 분리된 접근 방식을 사용할 수 있다.

이렇게 하나의 파일에 몰빵할 경우 하나의 중앙 저장소는 기존의 <App /> 컴포넌트가 아닌 전용 컨텍스트 컴포넌트(<AuthContext />), 전용 컨텍스트 파일(/src/store/auth-context.js)이 된다.

1. 중앙 저장소 만들기: state 및 컨텍스트 관리하는 컨텍스트 공급자 컴포넌트 만들기

📍 /src/store/auth-context.js

import React, { useState, useEffect } from "react";

//기본값
const AuthContext = React.createContext({
  isLoggedIn: false,
  //직접 사용 하지는 않지만 IDE 자동 완성 더 좋게 하기 위해 더미 함수 저장해 두기
  onLogout: () => {},
  //인자에 뭐 들어가는지도 작성해 두면 IDE 자동 완성에 좋음
  onLogin: (email, password) => {},
});

//✅ 컴포넌트로 공급자 컴포넌트를 만들 수 있음
//✅ 기본 값이 아닌, 명명 값으로 기본 값에 추가하여 AuthContextProvider 도 내보낸다.
export const AuthContextProvider = (props) => {
  
  //✅ App 컴포넌트에 있던 로직 다 여기로 옮기기
  //🔥 공급자 컴포넌트 안에서 isLoggedIn state와 setState 관리
  const [isLoggedIn, setIsLoggedIn] = useState();

  //🔥 useEffect도 여기로
  // 로그인 버튼 클릭하지 않아도 즉 loginHandler가 트리거 되지 않아도, 로컬스토리지에 isLoggedIn의 값이 1인 경우에는 로그인 유지하기
  useEffect(() => {
    const storedUserLoggedInInformation = localStorage.getItem("isLoggedIn");
    if (storedUserLoggedInInformation === "1") {
      setIsLoggedIn(true);
    }
  }, []);
  //디펜던시가 비어 있는 경우, 의존성이 없기 때문에 앱이 처음 시작되면 의존성이 변경된 것으로 간주되어 앱이 시작될 때 이펙트 코드는 딱 한 번만 실행됨

  //🔥 로그인 핸들러
  const loginHandler = (email, password) => {
    // We should of course check email and password
    // But it's just a dummy/ demo anyways
    localStorage.setItem("isLoggedIn", "1");
    setIsLoggedIn(true);
  };

  //🔥 로그아웃 핸들러
  const logoutHandler = () => {
    localStorage.removeItem("isLoggedIn");
    setIsLoggedIn(false);
  };

  //🔥이렇게 전체 인증 state를 이 별도의 공급자 컴포넌트에서 관리할 수 있음

  //✅ 반환하는 공급자 컴포넌트 <AuthContext.Provider>의 value로 값 넣어 주기
  return (
    <AuthContext.Provider
      value={{
        isLoggedIn: isLoggedIn,
        onLogout: logoutHandler,
        onLogin: loginHandler,
      }}
    >
      {props.children}
    </AuthContext.Provider>
  );
};

//이렇게하면 <AuthContextProvider /> 컴포넌트에서 전체 로그인 state를 관리하고, 모든 컨텍스트를 설정할 수 있다.

//기본값 내보내기
export default AuthContext;

이렇게 기존에 <App /> 컴포넌트에 있던 모든 로직을 컨텍스트 파일로 옮긴다.

  • 참고) IDE

2. 컨텍스트 공급자 컴포넌트로 앱 감싸기

이제 <AuthContextProvider /> 컴포넌트로 index.js에서 App을 렌더링 하는 부분을 감싸주자.

📍 /src/index.js

import React from "react";
import ReactDOM from "react-dom/client";

import "./index.css";
//AuthContextProvider 컴포넌트는 명명값으로 가져옴
import { AuthContextProvider } from "./store/auth-context";

import App from "./App";

const root = ReactDOM.createRoot(document.getElementById("root"));
//AuthContextProvider 컴포넌트로 앱을 감싸서 앱을 렌더링한다.
//이렇게 하면 모든 앱에서 컨텍스트를 가져다 쓸 수 있다.
root.render(
  <AuthContextProvider>
    <App />
  </AuthContextProvider>
);

3. 앱 컴포넌트 정리하기

📍 /src/App.js

import React, { useContext } from "react";

import Login from "./components/Login/Login";
import Home from "./components/Home/Home";
import MainHeader from "./components/MainHeader/MainHeader";
//파일 기본 값 임포트 하기
import AuthContext from "./store/auth-context";

function App() {
  // ✅ useContext() 사용하여 AuthContext 가져오기
  const ctx = useContext(AuthContext);
  return (
    //이제 하나의 저장소에서 관리하기 때문에 props으로 전달하던거 다 없애고 모두 컨텍스트로..
    <>
      <MainHeader />
      <main>
        {!ctx.isLoggedIn && <Login />}
        {ctx.isLoggedIn && <Home />}
      </main>
    </>
  );
}

export default App;

로그인 컴포넌트와 홈 컴포넌트에서도 useContext에 컨텍스트 파일을 임포트 해서 AuthContext를 포인팅하여 컨텍스트를 사용하게 하면 된다.

  • 이제 앱의 모든 곳에서 중앙집중적으로 관리되는 state와 컨텍스트를 사용할 수 있다.
  • <App /> 컴포넌트는 간결해졌다.
    이제 앱 전체 state관리와는 관련이 없고, JSX를 반환하는 것과 화면을 렌더링하는 본분에 더 충실할 수 있다.

💬 이렇게 중앙집중적으로 관리하는 것은 완전히 선택사항이다.
여튼 이렇게 하는거~ 저렇게 하는거~ 패턴 익혀 두면 좋으니 알아두자.


🌀 여전히 한계가 있는 context


1. 컨텍스트가 앱 전체 또는 컴포넌트 전체 state, 즉 여러 컴포넌트에 영향을 미치는 state에는 적합할 수 있지만, 컴포넌트 구성을 대체할 수는 없다.

  • 예) UI 폴더의 Button 컴포넌트: 버튼은 재사용 가능해야 한다.

현재 컨텍스트를 사용하여 사용자 클릭 시 항상 로그아웃되게 하고 있는데, 그렇게 하면 사용자를 로그아웃 시키는 것 외에 다른 용도로 재사용할 수 없다.

  • 로그인 컴포넌트에서 동일한 버튼 컴포넌트를 사용하여 폼을 제출을 트리거하여 사용자를 로그인시키고 있고,
  • 홈 컴포넌트에서도 동일한 버튼 컴포넌트를 사용하여 사용자를 로그아웃 시키고 있다.

이런 경우 버튼 컴포넌트에서는 컨텍스트를 사용하면 안된다. 오히려 prop을 사용하여 버튼을 구성하는 것이 좋다.

🌟 구성하려면 prop 사용하고, 컴포넌트 또는 전체 앱에서 state 관리하려면 context 사용하는 것이 좋다.

🌀 그런데 이렇게해도 한계가 있다.

2. 변경이 잦은 경우 리액트 컨텍스트는 적합하지 않다.

  • 예) 매초 또는 1초에도 여러번 state가 변경되는 경우

위와 같은 경우, 컨텍스트를 사용하고 싶은데 프롭은 또 적당하지 않은 것 같을 때는 어떻게 해야 할까?
그럴 때 쓰라고 사람들이 만들어둔게 있다. 그럴 땐 리덕스를 사용해보자! 😇

3. 중요한 것은 컨텍스트를 너무 남발하여 쓰지는 말라는 거다.

props은 여전히 컴포넌트 구성에서 필수적이고 중요하다.
긴~ props 체인을 교체하기 위해서 컨텍스트를 사용하는 것은 좋은 대안이지만, 모든 컴포넌트의 커뮤니케이션을 대체하기 위해 context를 사용하지는 말자~!

profile
Always have hope🍀 & constant passion🔥

0개의 댓글