본문 바로가기

javascript/react

[React] useContext Hook & createContext

React 내의 대부분의 상태 관리는 useState, useEffect, useRef 정도의 hook으로 처리 가능하다. 그러나 개발중인 서비스의 규모가 커지면 커질수록 데이터의 이동이 많아짐에 따라 Callback이나 Argument의 형태로 컴포넌트 사이의 데이터를 주고 받는 방식은 한계에 봉착한다.

위 그림은 리액트 컴포넌트간의 데이터 전송 방식을 나타낸 것이다. 녹색 화살표는 실인자로 데이터를 넘기는 방식을 나타내고, 주황색은 콜백을 이용하여 하위 컴포넌트의 데이터를 상위 컴포넌트로 전송하는 방식을 의미한다.

유저가 특정 컴포넌트에서 로그인하고, 해당 정보를 프로젝트 전반에 걸쳐 사용하는 경우를 생각해보자. 만약 유저의 정보를 유지하는 컴포넌트가 Component 4이고, 해당 정보를 이용하는 컴포넌트가 Component 5 라면 어떨까? 위 언급한 hook들만으로는 Component 4 에서 Component 5 로 데이터가 직접 이동할 수 없기에, 다음과 같이 이동한다.

Component 4 → Component 1 → App → Component3 → Component5

각각의 컴포넌트를 지날 때 한번의 함수 호출만 있다고 하더라도, 위 같은 상황에서는 적어도 5번의 함수 호출이 일어난다. 만약 컴포넌트의 개수가 100개라면? 프로젝트의 규모가 커지면 커질수록 useState만으로 처리하는 방식은 더 많은 오버헤드와 낮은 생산성을 가져올 것이다. 따라서 프로젝트 전반적으로 사용할 데이터의 유지 및 사용하기 위한 방법이 필요한데, Context는 이러한 요구를 만족하는 선택지 중 하나이며, React에 내장된 기능이다.

각 컴포넌트들은 Context을 통해 관리되는 데이터를 useContext hook을 통해 접근할 수 있다.

Context 사용 구조

앞서 언급했듯이 Context는 프로그램 전반적으로 데이터를 관리하고 사용하기 위한 방법이다. 이때 특정 변수나 객체를 Context로 관리하기 위해서는 createRef 및 useContext가 필요하다.

 

React.createContext

const context = React.createContext(initialState);
//초기 상태를 받는다.

React.createContext는 Context로 관리하고 싶은 state의 초기값을 실인자로 받고, 몇가지 기능을 가진 context을 반환한다. 이때 context는 단순한 객체로, 다음과 같은 프로퍼티를 가진다.

  • Provider : Context을 제공할 블럭을 지정하기 위한 JSX
  • Consumer : Context을 사용할 블럭을 지정하기 위한 JSX
  • displayName : 프로그램 개발 시 사용되는 DevTools에 출력될 이름을 지정한다. 

이때 Provider 및 Consumer에 집중하자. Context를 우리의 프로젝트에 제공하기 위해서는 Provider을 이용하여 해당 Context을 이용하고 싶은 JSX들을 묶어야 한다. 이때 Provider은 초기값 value을 필요로 한다.

context.Provider

return (
	<context.Provider
		value={{
        	arg1: 10,
            arg2: "string",
            arg3: {},
            ...
		}}>
		{props.children}
	</context.Provider >
);

context.Provider 컴포넌트의 태그 사이에 다른 컴포넌트들을 넣으면 해당 컴포넌트들은 Context을 소비할 수 있다. 이때 지정된 Context의 초기값은 value를 따르며, Provider은 자식 컴포넌트들까지도 적용된다. 이렇게 Context을 제공할 범위가 지정됬다면, 이를 소비할 범위를 지정하게 되는데, 이 역할을 수행하는 것이 context.Consumer이다.

 

context.Consumer

return (
	<context.Consumer>
		{(ctx) =>
			<ChildComponent1 property={ctx.arg1}/>
			<ChildComponent2 property={ctx.arg2}/>
			...
		}
	</context.Consumer>
);

context.Consumer은 자식 컴포넌트들에게 해당 Context 정보를 제공한다. 이때 Context는 lambda function 꼴로 제공되는데, 위 코드의 경우 ctx을 통해 자식 컴포넌트에서 Context 정보를 사용할 수 있다. 이때 component.Provider과는 달리 component.Consumer은 직접적으로 자식인 컴포넌트에 대해서만 작동한다. 즉, 상위 컴포넌트에서 Consumer을 지정했다고 하위 컴포넌트에서 자유롭게 Context에 접근할 수는 없다.

이때 궁금한게 있다. context.Provider이 초기값으로 value를 요구한다면, createContext의 실인자로 넘겼던 initialState는 대체 뭘 위해서 존재하는 걸까? 

사실 앞에서의 설명과는 달리 Context는 context.Provider 없이도 작동할 수 있다. Provider이 제공되지 않는 경우 Consumer은 react.createContext의 인자로 넘긴 initialState을 이용하게 된다. 보통 Provider 없이 사용할 일이 없기 때문에 이런 동작은 거의 의미가 없긴 하지만, 이때 넘긴 객체를 기반으로 자동완성이나 타입스크립트에서의 타입 검사가 발생하므로 구현해두는 것이 유용하다.

 

useContext

context.Consumer은 일종의 콜백 함수 형태로 구현되어 일반적인 컴포넌트들과 사용 방식이 다르다고 느낄 수 있다. 이런 사용방법에서의 미묘한 불편함을 없애주는 것이 useContext hook이다.

const myCtx = useContext(TargetContext);

useContext hook은 사용하고 싶은 Context을 실인자로 받는다. 이렇게 생성된 myCtx을 통해 Context을 직접 접근할 수 있으므로, 콜백 등이 필요 없어 직관적으로 사용할 수 있다. 이런 직관성 때문에 사실 대부분의 경우 Consumer보다는 useContext hook을 이용하는 편이다. 물론 두 방식 모두 문제가 없으며, 선택은 자신의 취향에 따르자.

 

Context의 구성

Context을 구성하는데 필요한 요소를 몇가지로 구분할 수 있다.

  1. createContext에 넘길 initialState
  2. createContext
  3. Context의 state을 관리하기 위한 코드 ( 다른 hook과 변수 등으로 이루어진 알고리즘 )
  4. Context의 Provider 파트
  5. Context의 Consumer / useContext 선언

createContext 및 useContext은 일반적인 함수에 불과하기 때문에, Context의 State을 관리하기 위해서는 기존의 hook을 활용해야 한다. 이때 Context의 목적을 고려하면 여러 컴포넌트들에 변수를 관리하기 위한 코드들이 분산되는 것은 적절하지 않기 때문에, 이를 구성하는데 있어 특정 Context에 대한 파일을 생성하고, 이를 

1. initialState

const initialState = {
    isLoggedIn: false,
    user: {} as UserInfo,
    onLogin: (id: string, password: string): void => { },
    onLogout: (): void => { }
};

useContext에 넘길 변수를 생성한다. 해당 변수를 기반으로 자동완성이 지원될 수 있다.

 

2. createContext

const AuthContext = React.createContext(initialState);

createContext을 이용하여 Context 객체를 생성한다.

 

3-4. Context의 state / Provider

Context의 State 역시 기본적으로 리액트의 hook을 기반으로 관리된다. 따라서 일반적인 컴포넌트를 구현하는 것처럼 Context와 관련된 State을 관리하도록 만들면 된다. 이후, 해당 State을 Provider에 value로 넘긴다.

타입스크립트를 이용하는 경우 children을 직접 지정해야 할 수도 있다. 이때 children들은 모두 JSX라는 것을 기억하자.

또한 이 과정을 통해 생성된 Provider 컴포넌트는 JSX으로 간주된다. 따라서 타입스크립트를 이용하는 경우 Context을 관리하는 파일의 확장자는 tsx이 되어야 한다.

export const AuthProvider = (props: { children: JSX.Element | JSX.Element[] }) => {
    const [isLoggedIn, setIsLoggedIn] = useState(false);
    const [userInfo, setUserInfo] = useState<UserInfo>(null);

    useEffect(() => {
        const pid = localStorage.getItem("LoggedIn");
        if (pid) {
            const user = localStorage.getItem(`user:${pid}`);

            if (user) {
                try {
                    const userObj = JSON.parse(user) as UserInfo;
                    setIsLoggedIn(true);
                    setUserInfo(userObj);
                }
                catch(e) {
                    console.log("cannot log in");
                }
            }
            else {
                // 유저 정보가 없다면 모든 정보 제거

                localStorage.removeItem("LoggedIn");
                localStorage.removeItem(`user:${pid}`);
            }
        }
    }, []);

    const loginHandler: loginHandle = async (id: string, password: string) => {
        const pid = `${Math.random()}`;
        const personalInfo = password;
        const userInfo: UserInfo = { userid: id, personalInfo: personalInfo };
        const userJson = JSON.stringify(userInfo);
        localStorage.setItem("LoggedIn", pid);
        localStorage.setItem(`user:${pid}`, userJson);
        setIsLoggedIn(true);
    };

    const logoutHandler = () => {
        const pid = localStorage.getItem("LoggedIn");

        localStorage.removeItem(`user:${pid}`);
        localStorage.removeItem("LoggedIn");
        setIsLoggedIn(false);
    };

    return (
        <AuthContext.Provider
            value={{
                isLoggedIn: isLoggedIn,
                user: userInfo,
                onLogin: loginHandler,
                onLogout: logoutHandler
            }}>
            {props.children}
        </AuthContext.Provider >
    );
};

 

5. Consumer

1~4와 Consumer 부분은 결이 다르다. 1~4는 특정 Context을 관리하기 위한 파일에 지정되는 반면, Consumer은 해당 Context을 직접 사용하기 위한 컴포넌트 내에 지정된다.

Consumer을 사용해도 상관 없다.

 

정리

Context은 프로젝트 전반적으로 정보를 유지 및 관리하는 것을 목적으로 하며, 이를 이용하면 컴포넌트들이 콜백이나 실인자를 통해 간접적으로 값을 전달하는 대신에 직접적으로 데이터를 교환할 수 있다. 단 Context 자체는 자주 변경되는 값 ( 1초에 한번씩 값이 변하는 경우 등 ) 에 대해서는 최적화 되어있지 않으므로 자주 변경되지 않는 상태를 관리하는 목적으로 사용되어야 하며, 이를 남용하는 경우 컴포넌트 간 결합도가 높아져 재사용성이 떨어질 수 있다. 따라서 값이 자주 변경되지 않는데, 해당 상태 자체는 프로젝트 전반적으로 사용될 수 있는 경우 - 유저의 개인 정보나 Authorization 등의 상황에서 사용하는 것이 바람직하다.