Context API + useReducer 조합 살펴보기 (feat. Typescript)

2022. 1. 9

상태관리?

리액트 개발을 할 때 상태관리 라이브러리를 사용하지 않으면 무수히 많은 props들과 싸우며 개발을 하게 된다.. 😨

그러한 문제를 해결하기 위해 많은 라이브러리(Redux, Recoil, Mobx 등)들이 등장했다.

위와 같은 상태 관리 라이브러리를 사용하지 않고 이번 기회에 간단한 Todo를 구현하며 Context APIuseReducer 조합을 통해 번거로운 prop drilling 없이 상태관리를 할 수 있는 방법에 대해 알아보자

Context API

먼저 Context API 의 개념에 대해 알아보자!

일단, 결론부터 말하자면 Context API는 상태관리를 하는 목적이 아니었다. 🙅🏻

Context API가 Redux와 같은 상태관리 라이브러리 대용으로 사용하는 경우와 예시의 한 경우로 자리매김 하여 상태관리의 역할을 하는 API로 이해를 하고 있을텐데 (나 또한 그랬다.. 😅)

리액트 공식문서에 따르면 생각과는 다르게 전혀 다른 말이 적혀있다.

Context API DOCS

위 말에서 그 어디에도 상태관리라는 말은 찾아볼 수가 없다.

여기서 말하고자 하는 결론은 일일이 props를 넘겨주지 않고 곧바로 제공하는 역할을 하는 친구라고 볼 수 있다.

즉, 굳이 상태가 아니더라도 데이터(값)라면 무엇이는 props를 넘기지 않고도 제공한다는 것이다.

리액트 개발을 할 때 Context API를 상태관리의 명목으로 많이 사용되고 있기 때문에 이런 오해에 의한 워딩이 생겨나 혼동을 일으키는 것 같다.. 🤔

그렇다면 Context API로 상태관리를 한다는건 무슨 의미일까?!

리액트에서 제공하는 훅인 useState, useReducer를 통해 직접 상태관리를 하는 상태함수들을 Context API로 prop drilling 없이 전달할 때 상태관리의 바지사장이라는 자리를 수여하게 된다.

그 이유는 개념적으로는 상태관리의 역할이 아니나, useState와 useReducer의 상태와 함수들을 처리하니 강제로 상태관리의 역할을 하는 자리에 앉혀놓은 느낌이라고 볼 수 있다.

진짜 상태관리는 현재 예제 기준으로는 useReducer가 하고 있는 셈이다.

코드를 보면서 어떻게 활용되는지 살펴보자!

import React, { useReducer, createContext } from 'react'

interface DefaultValueState {
  id: number
  text: string
}

const defaultValue: DefaultValueState[] = []

type ActionType =
  | {
      type: 'ADD_TODO'
      text: string
    }
  | { type: 'DELETE_TODO'; id: number }

function reducer(state: DefaultValueState[], action: ActionType): DefaultValueState[] {
  switch (action.type) {
    case 'ADD_TODO': {
      return state.concat({
        id: new Date().getTime(),
        text: action.text,
      })
    }
    case 'DELETE_TODO': {
      return state.filter((todo) => todo.id !== action.id)
    }
  }
}

interface ContextType {
  todos: DefaultValueState[]
  addTodo: (text: string) => void
  deleteTodo: (todoId: number) => void
}

export const TodoContext = createContext<ContextType>({
  todos: defaultValue,
  addTodo: () => {},
  deleteTodo: () => {},
})

function TodoProvider({ children }: { children: React.ReactNode }) {
  const [state, dispatch] = useReducer(reducer, defaultValue)

  const addTodo = (text: string): void => {
    dispatch({ type: 'ADD_TODO', text })
  }

  const deleteTodo = (id: number): void => {
    dispatch({ type: 'DELETE_TODO', id })
  }

  return (
    <TodoContext.Provider
      value={{
        todos: state,
        addTodo,
        deleteTodo,
      }}
    >
      {children}
    </TodoContext.Provider>
  )
}

export default TodoProvider

컨텍스트를 생성할 때 가장 기본값을 넣게 되는데, 이때 useReducer에서 관리할 상태함수를 가지고 생성하게 된다.

그리고 이렇게 생성된 컨텍스트를 useReducer에서 본격적으로 상태를 핸들링 하게 되고, 그 함수와 갱신될 상태를 children에게 넘겨주게 된다.

이렇게 되면 상태관리는 TodoProvider 컴포넌트에서 진행이 되지만, 이는 Context API를 통해 전역으로 drilling 해줄 수 있게 되는 것이다.

그렇다면 이걸 사용하는 컴포넌트의 모습은 어떨까?

App.tsx

import React from 'react'
import ReactDOM from 'react-dom'
import './index.css'
import App from './App'
import reportWebVitals from './reportWebVitals'

import TodoProvider from './components/todo/provider'

ReactDOM.render(
  <React.StrictMode>
    <TodoProvider>
      <App />
    </TodoProvider>
  </React.StrictMode>,
  document.getElementById('root'),
)

reportWebVitals()

Todo를 그려낼 컴포넌트에 대해서 상위 컴포넌트로 감싸준다.

Input.tsx

import React, { useContext, useState } from 'react'
import { TodoContext } from './provider'

function Input() {
  const { addTodo } = useContext(TodoContext)
  const [value, setValue] = useState('')

  const handleChangeInput = (e: React.ChangeEvent<HTMLInputElement>): void => {
    setValue(e.target.value)
  }

  const handleFormSubmit = (e: React.FormEvent<HTMLFormElement>): void => {
    e.preventDefault()
  }

  const handleClickButton = (): void => {
    addTodo(value)
    setValue('')
  }

  return (
    <form
      onSubmit={handleFormSubmit}
      style={{
        display: 'flex',
        justifyContent: 'space-between',
        width: 'inherit',
      }}
    >
      <input value={value} onChange={handleChangeInput} style={{ width: '80%' }} />
      <button type="button" onClick={handleClickButton}>
        등록
      </button>
    </form>
  )
}

export default Input

그리고 자식 컴포넌트에선 useContext hook을 통해 TodoProvider에서 drilling한 props를 바로 접근해서 사용이 가능하게 된다.

Input 컴포넌트에서는 등록을 해주는 역할이 있기 때문에 reducer 함수에서 ADD_TODO 타입인 dispatch 함수를 사용했다.

 Todos.tsx

import React, { useContext } from 'react'

import Item from './item'
import { TodoContext } from './provider'

function Todo() {
  const { todos } = useContext(TodoContext)

  return (
    <ul style={{ width: '100%', listStyleType: 'none', padding: 0 }}>
      {todos.map((todo) => (
        <Item key={todo.id} id={todo.id} text={todo.text} />
      ))}
    </ul>
  )
}

export default Todo

이제 추가한 Todo를 그려내야 할 때 todos 라는 명으로 상태를 주었으니 해당 상태를 가져와 렌더링을 시켜주었다.

위와 마찬가지로 DELETE_TODO의 dispatch도 처리해주었다.

Item.tsx

import React, { useContext } from 'react'

import { TodoContext } from './provider'

function Item({ id, text }: { id: number, text: string }) {
  const { deleteTodo } = useContext(TodoContext)

  const handleDeleteTodo = () => {
    deleteTodo(id)
  }

  return (
    <li>
      <div style={{ display: 'flex', justifyContent: 'space-between' }}>
        <span>{text}</span>
        <button onClick={handleDeleteTodo}>삭제</button>
      </div>
    </li>
  )
}

export default Item

이처럼 Context API와 useReducer 조합을 통해 props drilling 없이 상태관리를 해보았다.

공식 문서를 보지 않았다면 알 수 없는 개념이어서 공식문서를 읽어보는 것이 제공하는 API에 대한 의미와 목적을 파악하는 가장 기초적이고 중요한 방법이 아닐까 생각된다. 📚