본문 바로가기

React/Hooks

React Hooks #3 - Hooks(useContext)

useReducer와 함께 사용해서 global states로 사용 가능합니다.

Passing down as Props

컴포넌트 간 상태를 공유하기 위해서 Props를 서로 내려주는 상황이 많이 발생합니다.

쇼핑을 하기 위해서 상단의 검색어를 입력하면, 중단의 검색 결과와 하단의 검색 항목들이 변하게 됩니다. 또한 하단의 정렬 조건의 선택에 따라서 하단의 검색 항목들이 변하게 됩니다. 검색 항목들을 새롭게 가져오는 함수는 특정 컴포넌트에 종속되며 props로 전달하는 방식 혹은 데이터들을 props로 전달하는 방식을 선택할 수 있습니다. 또한 만약 적은 데이터 수를 가지고 있다면 정렬 조건에 따라서 기존 가지고 있던 데이터들을 새로 불러오는 것이 아니라 데이터들을 정렬하는 상황이 있을 수 있습니다. 컴포넌트 간 바로 연결되어 있는 정보들을 props로 전달하는 것은 명확한 소스 코드를 작성하지만, depth를 가지고 멀리 떨어져 있는 경우에는 데이터 흐름을 보기 위해서 많은 컴포넌트들을 navigation 해야 합니다. 특히 회원가입과 같은 사용자가 많은 부분을 선택해야 하는 경우, 그리고 각 선택의 선행 관계가 존재하는 경우 props navigation이 과중하게 느껴질 수 있습니다.

이러한 어려움은 사실 데이터를 어느 부분에서 관리할 것인가와 크게 관련이 있습니다. props - states만 사용해야 한다면 Container에서 states를 선언하고 states를 변경할 수 있는 함수들을 비즈니스 로직에 맞게 작성해서 각 변경을 담당하는 곳에 전달을 하는 방법이 있을 수 있습니다. (위 예시에서는 root Component인 App.tsx / index.tsx에 상태들을 선언하고, 그 부분을 변경하는 함수도 가지고 있어서 red, orange, yellow, blue boxes에 전달을 할 수 있겠습니다). Container에서 상태를 관리하고 있어서 요건의 변경에 따라서 함수 및 관리할 상태들을 변경하는 것은 해당 파일을 찾아서 할 수 있습니다. 그러나 yellow, blue boxes를 가지고 있는 컴포넌트(green이라고 하겠습니다)의 입장에서는 yellow, blue의 상태 및 상태 변경에 따른 콜백 함수와 같은 것보다는 텝을 이동하는 것에 따른 인터렉션 처리가 본 기능입니다. 본 기능 이외에도 하위 컴포넌트의 기능을 구현하기 위해서 전달받고 전달해야 하는 부분은 유지보수를 어렵게 하며 하나의 추가 및 삭제에 대해서 많은 파일을 수정해야 하는 상황이 발생합니다.

Context API

Context는 React 컴포넌트 트리 안에서 전역적(global)이라고 볼 수 있는 데이터를 공유할 수 있도록 고안된 방법입니다. Context는 크게 Provider, Consumer로 구분이 되며 이름에서 유추할 수 있듯이 provider(sets the value), consumer(uses the value)의 기능을 합니다.

 

// App.tsx

const defaultValue = {
	primaryColor: 'skyblue',
};

// using without value props, defalut exposed

const ColorContext = React.createContext(defaultValue);

export const App = () => (
	<ColorContext.Provider value={{ primaryColor: 'gray' }}>
		{/* would be the color as gray */}
		<div>
			<MyComponent/>
		</div>
	</ColorContext.Provider>
);

export default App;

 

브라우저에서 제공하는 다크 모드처럼, 구성하고자 하는 페이지에 대해서 특정 배경색을 설정하고 싶은 상황이 있을 경우 다음과 같이 사용할 수 있습니다. 이런 부분처럼 로그인한 유저, 테마, 선호하는 언어 등에 사용이 가능합니다. 이렇게 Provider로 선언된 Context를 기준으로 구성되는 Component Tree내부의 컴포넌트들은 Consumer를 통해서 접근할 수 있습니다.

 

import { ColorContext } from './App';

// any-where inside of App.tsx's Component Tree

export const TitleSection = ({ title: string, popup: () => void }) => {
	const handleClick = (e: MouseEvent) => {
		// e.preventDefault();
		alert('button clicked');
		popup(e.target);
	}

	return (
		<ColorContext.Consumer>
			{contextObject => (
				<section>
					<h2 style={{ color: contextObject.primaryColor }}>{title}</h2>
					<input type="button" onClick={handleClick}>button</input>
				</section>
			)};
		</ColorContext.Consumer>
	);
};

export default TitleSection;

 

Consumer대신에 contextType property로 지정하는 방식도 가능합니다.

 

import { ColorContext } from './App';

export default class TitleSection extends React.Component {
	// some codes for props-states
	render() {
		const contextObject = this.context;
		return (
			<section>
				<h2 style={{ color: contextObject.primaryColor }}>{title}</h2>
				<input type="button" onClick={handleClick}>button</input>
			</section>
		);
	}
}

TitleSection.contextType = ColorContext;

 

만약 public class fields syntax (https://babeljs.io/docs/en/babel-plugin-proposal-class-properties)를 사용하고 있다면, static contextType를 지정할 수 있습니다.

 

class MyClass extends React.Component {
	static contextType = MyContext;
	render() {
		const value = this.context;
		// some codes for rendering
	}
}

 

다만 로그인 정보와 테마 정보를 함께 사용하는 경우 nested Provider에 대해서 nested contextType를 지정하는 것은 불가능해서, nested Consumer를 통해서 접근할 수 있습니다. 

 

import React, { useContext } from 'react';
import { ColorContext } from './App';

export const TitleSection: React.FC = ({ title: string, popup: () => void }) => {
	const contextObject = useContext(ColorContext);

	...
}

export default TitleSection;

 

hooks를 통해서 간단하게 작성이 가능합니다. 정적인 값을 전달하는 것에서, Context의 값을 변경하는 방식은 변경 함수를 함께 전달하는 것으로 구현합니다.

 

// App.tsx

const defaultValue = {
	primaryColor: 'skyblue',
	toggleColor: () => {}
};

const ColorContext = React.createContext(defaultValue);

export default class App extends React.Component {
	constructor(props) {
		super(props);

		this.toggleColor = () => {
			this.setState(state => ({
				primaryColor:
					state.primaryColor === 'skyblue'
					? 'gray'
					: 'skyblue',
			}));
		};

		this.state = {
			primaryColor: 'gray',
			toggleColor: this.toggleColor,
		};
	}
	
	render (    
		<ColorContext.Provider value={this.state}>
			<div>
				<MyComponent/>
			</div>
		</ColorContext.Provider>
	);
}

Breakpoints

Context API를 통해서 Global에 공유되어야 하는 값을 선언(이미지)하고 해당 값을 접근하는 방식을 택해 소스 코드 구성을 더욱 간략하게 할 수 있습니다. 그러나 무분별한 사용은 어플리케이션 성능 저하로 돌아오게 됩니다. 하나의 컴포넌트에서 다수의 Context를 구독하는 방식 혹은 다수의 컴포넌트가 하나의 Context를 구독하는 방식은 Context Object의 depth가 깊다면 불필요한 연산을 진행하게 됩니다. props - states의 구조는 props가 변경되는 것에 따라서 자식 컴포넌트가 re-render 하는 것으로 동작하는 것은 명확하지만 Context Object를 변경하는 toggle 함수를 실행했을 때에는 많은 구현 방법이 존재하고 해당 부분에 따른 trade-off가 존재합니다.

 

// case 1 : returns a new object
constructor(props) {
	super(props);

	this.toggleThemeAsBlack = () => {
		this.setState(state => ({
			...state,
			theme: 'dark',
			color: 'white',
		}));
	};
}

// case 2 : returns same object address with changed inner value
constructor(props) {
	super(props);

	this.toggleThemeAsBlack = () => {
		this.setState(state => {
			state.theme = 'dark';			
			state.color = 'white';
			return state;
		});
	};
}

// case 3 : shouldComponentUpdate by passing props from context object
shouldComponentUpdate(nextProps, nextState)

// case 4 : treat as PureComponent by passing props from context object
class MyComponent extends React.PureComponent

 

Context Object의 theme / color를 변경하는 함수가 존재한다고 가정을 했을 때, 새로운 객체를 반환하는 방식변경 부분만 반영한 같은 주소 값을 가지는 객체를 반환하는 방식이 있습니다. 새로운 객체를 반환하는 것은 객체 자체를 props로 전달해야 하는 상황이 있을 때 shouldComponentUpdate cycle에서 주소 값의 비교를 하는 것을 통해서 간결하게 작성할 수 있습니다. 또한 PureComponent를 사용한다면 shallow compare를 진행하기 때문에 변경사항에 따른 반영을 자동으로 할 수 있습니다. 이러한 관점에서 객체 자체에 접근해서 변경하는 것을 막는 immutable(https://reactjs.org/docs/update.html), 불변성을 보장하기 위해서 라이브러리(immer.js - https://github.com/immerjs/immer, immutable.js - https://immutable-js.com)들을 사용하거나 object.assign / spread operator를 사용해서 새로운 객체를 반환하도록 소스 코드를 작성합니다. 

이에 대비적으로 변경 부분만 반영한 같은 주소 값의 객체를 반환하는 것은 변경점이 없는 부분들에 대해서 자동으로 passing 할 수 있는 것을 의미합니다. useEffect의 dependency array에 대해서 state.my1stDep.value와 같이 state자체를 의존성 확인의 인자로 넣는 방식을 채택하는 것을 통해서 변경이 없는 부분에 대해서 re-render를 하지 않도록 유도할 수 있습니다. 그러나 같은 주소 값의 객체를 반환하는 것은 생각보다 위험합니다. 발생으로 인한 에러에 비해서 미발생으로 인한 에러는 이슈 트레킹과 그 해결이 어렵기 때문입니다. 이와 더불어 어느 부분은 re-render logic을 유도하고, 어느 부분은 하지 않을 것인지에 대한 부분이 각 컴포넌트마다 다르게 적용되어야 하기 때문에 유지보수가 어려울 수 있습니다.

 

다시 본론으로 돌아와서 성능 이슈에 대해서 말씀을 드리겠습니다. toggleTheme과 같은 Context Object를 변경하는 함수가 무겁다면, 그리고 많은 상태들과 많은 함수들을 객체가 포함하고 있어야 한다면 상태 변경에 필요한 비용은 이에 비례해서 증가하게 됩니다. 그래서 props - states로 특정 상태 변경에 필요한 함수와 그 상태를 전달하는 것에 비해 성능 저하를 보이고 이는 전체 어플리케이션의 성능 저하로 이어질 수 있습니다.

또한 root Component가 아닌 트리의 중간 단계에 해당하는 컴포넌트가 Provider로 선언되어 있다면 해당 컴포넌트 기준으로 부모 컴포넌트가 렌더링 될 때마다 불필요하게 하위 컴포넌트가 다시 렌더링 되는 문제가 생길 수 있습니다. 특히 제어의 역전(inversion of control)을 통해서 해결할 수 있는 부분은 해당 방식으로 작성하는 것이 성능 저하를 막을 수 있습니다. (https://reactjs.org/docs/context.html#before-you-use-context)

 

재사용성에 대한 측면으로도, Context Object를 사용하는 순간부터 의존성이 생기기 때문에, 해당 부분을 유연하게 사용할 수 있었던 props - states에 비해서 제약성이 커지게 됩니다. 개인적으로는 통합성을 위해서 Context Object가 없는 경우에 대해서 분기 처리를 한 파일에서 하는 것보다는 각각의 파일로 분리하는 것이 맞다고 생각합니다. BaseComponent를 작성하고 그 후 ContextComponent를 해당 컴포넌트를 extends 하는 방식으로 작성하게 된다면 atomic design을 구성하는 것에 걸림돌이 작게나마 사라질 것입니다. (Context API를 사용하더라도, https://ansrlm.tistory.com/31와 같은 방식으로 storybook 작성은 가능합니다)

useContext with useReducer hooks

useReducer를 통해서 상태와 상태에 대한 변경을 통합하고 상태와 그 변경 함수를 useContext를 통해서 global에 작성한다면 특정 파일에서 상태 변경에 대한 로직을 관리하기 때문에 유지보수와 확장성을 확보할 수 있습니다.

 

// App.tsx

import React, { createContext, useReducer } from 'react';
import { reducer, initialState } from './reducer';
import { State } from './reducer/states';

export const Context = createContext({} as { state: State; dispatch: React.Dispatch<any> });

export const App: React.FC = () => {
	const [state, dispatch] = useReducer(reducer, initialState);

	return (
		<Context.Provider value={{ state, dispatch }}>
			<MyComponent/>
		<Context.Provider/>
	);
}

 

// MyComponent.tsx

import React, { useContext } from 'react';
import { CommonActionType } from '../../reducer/actions';
import { Context } from '../../App';

type Props = unknown;

export const MyComponent: React.FC<Props> = () => {
	const { state, dispatch } = useContext(Context);

	const handleClick = () => {
		dispatch({
			type: CommonActionType.CHANGE_STYLE,
			theme: { theme: 'dark', color: 'white' },
		});
	}

	return (
		<>
			{state.common.hasAuth && (
				<button onClick={handleClick}>스타일변경버튼</button>
			)}
		</>
	)
}

export default MyComponent;

 

 

 

 

※ 잘못된 부분이나 궁금한 점 댓글로 작성해 주시면 최대한 답변하겠습니다. 읽어주셔서 감사합니다.

※ React Hooks #4 - Hooks(useTranslation)에서 어플리케이션 다국어 지원을 사용하는 방식에 대해서 소개해보겠습니다. -> Hook flow와 Suspense를 소개해 보도록 하겠습니다.

'React > Hooks' 카테고리의 다른 글

React Hooks #5 - Hooks(useRef)  (0) 2021.09.24
React Hooks #4 - Hook Flow  (0) 2021.09.22
React Hooks #2 - Hooks(useReducer)  (0) 2021.08.16
React Hooks #1 - Motivation  (0) 2021.07.04