[React] 라이브러리 없이 전역 상태관리를 해보자

강경서·2023년 11월 8일
0
post-thumbnail

Intro

React로 애플리케이션을 만들다 보면 "전역 상태 관리 라이브러리가 필요하겠는걸?" 하는 상황을 자주 마주하게 됩니다. 이는 React가 단방향으로 데이터가 흐르기에 상위 컴포넌트로 상태를 전달하기 까다롭기도 하고 규모가 커지는 애플리케이션일수록 컴포넌트가 많아져 상태를 개별적으로 관리하기에는 힘들어지기 때문입니다.

위와 같은 상황에서 저는 Redux , Recoil과 같은 전역 상태 관리 라이브러리를 설치하여 사용했습니다. 라이브러리는 무척 편하고 효율도 좋아 만족도가 높았지만, 간단한 프로젝트에서 몇 개 안 되는 상태를 전역으로 관리하기 위해 라이브러리를 무조건 설치해야만 하는 상황이 오히려 비효율적이라 생각이 들어 React에서 자체적으로 제공되는 기능으로 전역 상태가 관리할 수 있는 방식을 알아보게 되었습니다.


React에서 전역 상태 관리하기

React에서 제공하는 Context API , useReducer를 사용하여 전역 상태를 관리할 수 있습니다. 두 가지 기능 모두 자주 사용해 보지 않아 이번 기회에 확실히 알아가며 배우고, 전역 상태 관리까지 구현해 보았습니다.


Context API

Context API를 이용하면 단계마다 일일이 props를 넘겨주지 않고도 컴포넌트 트리 전체에 데이터를 제공할 수 있습니다. 일반적인 React 애플리케이션에서 데이터는 위에서 아래로 (즉, 부모로부터 자식에게) props를 통해 전달되지만, 애플리케이션 안의 여러 컴포넌트들에 전해줘야 하는 props의 경우 (예를 들면 UI 테마) 이 과정이 번거로울 수 있습니다. 즉 Context API를 이용하면 prop drilling을 방지하며, 컴포넌트들이 상태를 공유할 수 있습니다.

prop drilling : props를 오로지 하위 컴포넌트로 전달하는 용도로만 쓰이는 컴포넌트들을 거치면서 React Component 트리의 한 부분에서 다른 부분으로 데이터를 전달하는 과정입니다.

createContext

createContext를 통해 context를 생성할 수 있습니다. defaultValue가 없으면 null을 지정할 수 있습니다.

const SomeContext = createContext(defaultValue)

이렇게 생성된 context를 이용하여 SomeContext.Provider로 컨텍스트 값을 지정할 수 있고, useContext(SomeContext)를 통해 이를 읽을 수 있습니다.

useContext

구성 요소의 컨텍스트를 읽고 구독할 수 있게 해주는 React Hook입니다 .

const value = useContext(SomeContext)

Context 통해 전달된 상태 사용하기

SomeContext.Provider를 통해 구성 요소를 컨텍스트 공급자로 래핑하여 내부의 모든 구성 요소에 대해 컨텍스트 값을 지정합니다. SomeContext.Provider의 value값에 컨텍스트 값을 지정할 수 있습니다.

아래는 theme값에 따라 변경되는 예제입니다.

import { createContext, useContext } from 'react';

const ThemeContext = createContext(null);

export default function MyApp() {
  return (
    <ThemeContext.Provider value="dark">
      <Form />
    </ThemeContext.Provider>
  )
}

function Form() {
  return (
    <Panel title="Welcome">
      <Button>Sign up</Button>
      <Button>Log in</Button>
    </Panel>
  );
}

function Panel({ title, children }) {
  const theme = useContext(ThemeContext);
  const className = 'panel-' + theme;
  return (
    <section className={className}>
      <h1>{title}</h1>
      {children}
    </section>
  )
}

function Button({ children }) {
  const theme = useContext(ThemeContext);
  const className = 'button-' + theme;
  return (
    <button className={className}>
      {children}
    </button>
  );
}

Context 통해 전달된 상태 변경하기

useState를 사용하여 컨텍스트 값을 변경할 수 있습니다.

function MyPage() {
  const [theme, setTheme] = useState('dark');
  return (
    <ThemeContext.Provider value={theme}>
      <Form />
      <Button onClick={() => {
        setTheme('light');
      }}>
        Switch to light theme
      </Button>
    </ThemeContext.Provider>
  );
}

이렇게 Context API를 이용한다면 전역적으로 상태를 관리할 수 있습니다. 다만 관리해야할 상태가 복잡한 구조를 가지거나 상태를 업데이트하는 방식이 단순하지 않다면 useReducer와 결합하면 전역적인 상태 관리를 효율적으로 할 수 있습니다.


useReducer

useReducerReact에서 상태를 관리하는데 많이 사용하는 useState와 같은 React Hook입니다. 비교적 간단한 상태를 관리하는데에 useState를 주로 사용하지만 관리해야할 상태가 복잡하거나 논리적인 상태 업데이트가 필요하다면 useReducer를 이용하는 편이 효율적일 수 있습니다.

const [state, dispatch] = useReducer(reducer, initialArg, init?)
  • reducer : state가 업데이트되는 방법을 지정하는 함수입니다. stateaction을 인수로 가져와야 하며 업데이트된 state를 반환해야 합니다.

  • initialArg : 초기 state가 계산되는 값입니다. 모든 유형의 값이 될 수 있습니다.

  • init : 초기 state를 반환해야 하는 초기화 함수입니다. 지정되지 않은 경우 초기 상태는 으로 설정됩니다 .

dispatch

useReducer에서 반환된 dispatch를 사용하여 state를 업데이트할 수 있습니다.

dispatch(action);
  • action : 사용자가 선택한 state의 업데이트 방식을 지정하는 객체형태의 매개변수입니다. 관례적으로 type을 통해 업데이트의 방식을 구분하고 선택적으로 추가 정보가 포함할 수 있습니다.
dispatch({type:'incremented_age');

reducer 함수 작성하기

function reducer(state, action) {
  // ...
}

reducer 함수는 stateaction을 인수로 가져와야 하며 state를 계산하고 반환하는 코드를 작성해야 합니다. 관례적으로는 switch 선언문으로 작성하는 것이 일반적입니다.

아래는 reducer함수의 예제입니다.

function reducer(state, action) {
  switch (action.type) {
    case 'incremented_age': {
      return {
        name: state.name,
        age: state.age + 1
      };
    }
    case 'changed_name': {
      return {
        name: action.nextName,
        age: state.age
      };
    }
  }
  throw Error('Unknown action: ' + action.type);
}

dispatch가 보내는 actiontype값에 따라 state가 업데이트가 됩니다. 이렇게 다양한 actiontype을 추가하여 논리적으로 상태 관리가 가능합니다.


Context API와 useReducer를 이용한 전역 상태 관리

useReducerstatedispatch함수를 모두 context에 포함시킨다면 context의 내부의 컴포넌트들은 props없이 state를 공유 및 업데이트가 가능합니다. 즉 state를 전역으로 관리할 수 있게됩니다.

아래는 직접 작성한 예제입니다.

import React, {
  Dispatch,
  Reducer,
  createContext,
  useContext,
  useReducer,
} from "react";

const TodoStateConText = createContext([]);
const TodoDispatchConText = createContext(() => {});

function reducer(state, action) {
  switch (action.type) {
    case "ADD_TODO":
      state.push(action.todo);
      return state;
    case "Delete_TODO":
      return state.filter((prev) => prev.id !== action.todo.id);
    default:
      throw new Error();
  }
}

function TodoContext({ children }) {
  const [state, dispatch] = useReducer(reducer, []);
  return (
    <TodoStateConText.Provider value={state}>
      <TodoDispatchConText.Provider value={dispatch}>
        {children}
      </TodoDispatchConText.Provider>
    </TodoStateConText.Provider>
  );
}

export default TodoContext;

export function useTodoState() {
  const state = useContext(TodoStateConText);
  return state;
}

export function useTodoDispatch() {
  const dispatch = useContext(TodoDispatchConText);
  return dispatch;
}

useReducer을 통해 반환한 statedispatch를 각각의 context로 만들어 context내에서 이들을 공유할 수 있게 만들어줍니다.

import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App";
import TodoContext from "./TodoContext";

const root = ReactDOM.createRoot(
  document.getElementById("root") as HTMLElement
);
root.render(
    <TodoContext>
      <App />
    </TodoContext>
);

전체 애플리케이션을 위에 만들어둔 context로 감싸줍니다. 이제 전체 애플리케이션에서 statedispatch를 공유할 수 있게 되었습니다.

import React, { useState } from "react";
import Button from "../Atoms/Button";
import Input from "../Atoms/Input";
import { isTodoEditState, todosState } from "../../atom";
import { useTodoDispatch } from "../../TodoContext";

function TodoForm() {
  const [newTodo, setNewTodo] = useState("");
  const dispatch = useTodoDispatch();

  const onChange = (event) => {
    const {
      target: { value },
    } = event;
    setNewTodo(value);
  };

  const onSubmit = (event) => {
    event.preventDefault();
    dispatch({
      type: "ADD_TODO",
      todo: {
        id: Date.now(),
        content: newTodo,
        isChecked: false,
        isEdited: false,
      },
    });
    setNewTodo("");
  };

  return (
    <Form onSubmit={onSubmit}>
      <Input type={"string"} inputValue={newTodo} onChange={onChange} />
      <Button buttonValue={"추가하기"} disabled={isTodoEdit} />
    </Form>
  );
}

export default TodoForm;

useContext로 구성되어 있는 useTodoDispatchdispatch를 이용하여 state를 변경합니다.

import TodoList from "./TodoList";
import { useTodoState } from "../../TodoContext";

function TodoLists() {
  const state = useTodoState();
  return (
    <ul>
      {state.map((todo) => (
        <TodoList key={todo.id} todo={todo} />
      ))}
    </ul>
  );
}

export default TodoLists;

useContext로 구성되어 있는 useTodoStatestate를 전역으로 사용할 수 있습니다.


📝 후기

전역 상태 관리가 필요하다면 고민 없이 라이브러리를 찾고는 했는데 라이브러리 의존 없이 전역 상태 관리를 구현해서 만족스러웠습니다. 그뿐만 아니라 익숙하지 않아 기피했던 Context APIseReducer를 알아보고 사용해 볼 수 있어서 좋은 경험이었습니다. 물론 Redux, Recoil과 같은 상태 관리 라이브러리는 더욱 다양한 기능을 제공하니 상황에 따라서 이들을 택하는 게 더욱 효율적일 수 있지만, 기본적인 상태 관리만 한다면 애플리케이션의 의존성을 줄일 수 있는 위와 같은 방법도 고려해 볼 수 있겠습니다.


🧾 Reference

profile
기록하고 배우고 시도하고

0개의 댓글