본문 바로가기

카테고리 없음

useReducer, useContext를 사용한 전역 상태 관리(with React, TS)

프로젝트 아키텍처를 짜던 중, 고민할 문제가 생겼다.

바로 "상태관리" !

프로젝트 특성상 상태를 계속 들고 있어야 했고, 그 상태에 따라 하나의 페이지에서 바로바로 렌더링을 해줘야 했기 때문에 전역 상태관리가 필수적이었다.

 

기존에 진행했던 프로젝트에서는 'zustand'를 사용해 쉽게 전역 상태관리를 진행했지만, 라이브러리의 사용이 제한되어 있으므로 다른 해결 방법이 필요했다.

 

그래서 발견한 react hook이 useContext, useReducer다.

근데 일단 이게 뭔지 모르니까?

 

useReducer


보통 React에서 상태를 관리할 때는 useState를 많이 사용한다.

const [state, setState] = useState(0);

return (
	<p>{state}</p>
    <button onClick={setState(1)}>1로 만들기</button>
    )

이렇게 state값을 setState로 변경해주면, 관련된 부분이 리렌더링된다.

간단한 상태라면 useState를 사용하는 것이 편하지만, 그 구조가 복잡해진다면 useReducer를 사용하는 것이 용이하다.

 

그래서 어떻게 쓰는데 !

const [state, dispatch] = useReducer(reducer, initialState);

이런 방식으로 사용한다.

state는 우리가 사용할 상태고, dispatch는 액션을 일으킬 함수를 말한다. (dispatch({type: 'INCREMENT'}))

initialState는 말 그대로 초기 상태를 넣는 부분이다.

여기서 reducer에 들어갈 부분은,

function reducer(state, action) {
  // 새로운 상태를 만드는 로직
  // const nextState = ...
  return nextState;
}

같이 새로운 상태를 만들어주는 함수다.

 

따라서 이런 방식으로 사용할 수 있다.

function reducer(state, action) {
  switch (action.type) {
    case 'INCREMENT':
      return state + 1;
    case 'DECREMENT':
      return state - 1;
    default:
      return state;
  }
}

function Counter() {
  const [number, dispatch] = useReducer(reducer, 0);

  const onIncrease = () => {
    dispatch({ type: 'INCREMENT' });
  };

  const onDecrease = () => {
    dispatch({ type: 'DECREMENT' });
  };

  return (
    <div>
      <h1>{number}</h1>
      <button onClick={onIncrease}>+1</button>
      <button onClick={onDecrease}>-1</button>
    </div>
  );
}

export default Counter;

이러한 부분을 따로 함수로 빼서 작성한다면, 하나의 파일에 종속된 상태변경이 아닌 어디서나 사용할 수 있는 상태변경 함수처럼 사용할 수 있다.

 

useContext


React를 하면서, 상위 컴포넌트에서 하위 컴포넌트로 props를 넘겨줘야 하는 일이 많다.

하지만 depth가 깊은 컴포넌트라면 ...

내리고 ... 내리고 ... 또 내리고 ...

하나의 값을 변경하기 위해 여러번 props를 내려줘야 하는 props drilling이 발생하게 된다.

이러한 문제를 해결하기 위해 useContext 를 사용한다!

 

const themes = {
  light: {
    foreground: "#000000",
    background: "#eeeeee"
  },
  dark: {
    foreground: "#ffffff",
    background: "#222222"
  }
};

const ThemeContext = React.createContext(themes.light);

function App() {
  return (
    <ThemeContext.Provider value={themes.dark}>
      <Toolbar />
    </ThemeContext.Provider>
  );
}

function Toolbar(props) {
  return (
    <div>
      <ThemedButton />
    </div>
  );
}

function ThemedButton() {
  const theme = useContext(ThemeContext);
  return (
    <button style={{ background: theme.background, color: theme.foreground }}>
      I am styled by theme context!
    </button>
  );
}

이 코드는 React 공식 문서에 나와있는 코드다.

useContext는 React.createContext가 반환한 context 객체를 인자로 받는다.

그 후 useContext가 호출된 컴포넌트로부터 가장 가까이 있는 해당 context의 Provider를 찾아서 그 Provider의 value를 반환한다.
만일 해당 context의 Provider를 찾지 못 했다면 createContext의 인자로 넘겨준 값을 반환한다.

최상위 컴포넌트를 context.Provider로 감싸준다면 코드 전역에서 해당 context를 참조할 수 있다라는 것이다.

또한 useContext를 호출한 컴포넌트는 해당 context의 값이 변경되면 항상 리렌더링 된다.

하지만 context에 담긴 값을 하위 컴포넌트에서 수정할 수는 없다.

전역으로 상태를 관리하려면 값을 가져오는 것 뿐 아니라 수정할 수 있어야 하는데, 그럼 어떻게 할까?

 

useContext + useReducer로 Redux 패턴 흉내내기


useReducer와 useContext를 조합하여 사용하면 redux 패턴을 흉내낼 수 있다.

어떤 depth에 위치한 컴포넌트든 useContext와 action dispatch로 state를 읽어오고 수정할 수 있다.

 

// App.tsx

import React, { useReducer, createContext, Dispatch } from 'react';
import Count from './components/Count';

type CountStateT = {
  "count": number
}
type CountActionT = {
  type: "INCREASE"
}

const initialState: CountStateT = {
  "count": 0
}

const countReducer = (state: CountStateT, action: CountActionT): CountStateT => {
  switch (action.type) {
    case 'INCREASE':
      return { count: state.count + 1 };
    default:
      throw new Error("invalid action type");
  }
}

export const countStateContext = createContext(0);
export const countDispatchContext = createContext<Dispatch<CountActionT> | null>(null);

const App = () => {
  const [state, dispatch] = useReducer(countReducer, initialState);

  return (
    <countDispatchContext.Provider value={dispatch}>
      <countStateContext.Provider value={state.count}>
        <Count />
      </countStateContext.Provider>
    </countDispatchContext.Provider>
  );
};

export default App;
// Count.tsx

import React, { useContext } from 'react'
import { countDispatchContext, countStateContext } from '../App'

const Count = () => {
  const count = useContext(countStateContext); // 5
  const dispatch = useContext(countDispatchContext);

  if(!dispatch) throw new Error("dispatch is null");
  
  return (
    <div>
      <p>Test count: {count}</p>
      <button onClick={() => {dispatch({type: 'INCREASE'})}}>+</button>
    </div>
  )
}

export default Count

차근차근 살펴보자.

const countReducer = (state: CountStateT, action: CountActionT): CountStateT => {
  switch (action.type) {
    case 'INCREASE':
      return { count: state.count + 1 };
    default:
      throw new Error("invalid action type");
  }
}

리듀서를 선언하는 부분이다. INCREASE라는 액션은 count 값을 1 올려주는 역할을 한다.

  const [state, dispatch] = useReducer(countReducer, initialState);

useReducer 훅에 작성한 리듀서 함수를 넣어준다.  initialState는 처음 선언한 {count:0}의 값이 들어간다.

export const countStateContext = createContext(0);
export const countDispatchContext = createContext<Dispatch<CountActionT> | null>(null);

createContext를 통해 Context를 생성한다. 하나만 만들어도 상관 없지만, 이 예제에서는 state만 사용하는, dispatch만 사용하는 컴포넌트를 고려해 두가지로 분기해 작성했다.

return (
    <countDispatchContext.Provider value={dispatch}>
      <countStateContext.Provider value={state.count}>
        <Count />
      </countStateContext.Provider>
    </countDispatchContext.Provider>
  );

Provider로 컴포넌트를 감쌌다. 이러면 Count 컴포넌트에서 작성한 countStateContext, countDispatchContext를 사용할 수 있다.

보통 React Router로 페이지를 관리하기 때문에, 이러한 경우에는

const Provider = ({children}:props): JSX.Element => { ...

return (
    <countDispatchContext.Provider value={dispatch}>
      <countStateContext.Provider value={state.count}>
        {children}
      </countStateContext.Provider>
    </countDispatchContext.Provider>
  );
  }

이런 형태로 선언하고,

function App() {
  return (
    <Provider>
      <BrowserRouter>
        <Routes>
          <Route path="/" Component={HomePage} />
          <Route path="/recommend" Component={RecommendPage} />
          <Route path="/estimate" Component={vehicleEstimationPage} />
          <Route path="/result" Component={ResultPage} />
        </Routes>
      </BrowserRouter>
    </Provider>
  );
}

이런 식으로 라우팅을 관리하는 페이지에서 전역으로 관리하고 싶은 state가 있는 페이지를 감싸주면 된다. 그러면 자동으로 children에 하위 요소들이 담겨 모두 그 값을 사용할 수 있다 !

사용할 때는

const conutState = useContext(countStateContext);

의 형식으로 state 값을 사용하거나, dispatch를 불러와 새로운 값을 세팅하거나 관련 함수를 실행하는 방식으로 사용하면 된다.

 

항상 상태관리 라이브러리를 사용해서 어떻게 동작하는지 자세한 내용을 알지 못했지만, 관련 내용을 공부하면서 큰 틀은 이해하게 된 것 같다.

이런 공부를 할 때마다 라이브러리를 만든 사람들은 정말 천재같다는 생각이 든다... 결론은, 다음부턴 zustand 써야겠다 !