Redux 개념
기존 프로젝트에서 useState hook을 이용해 상태를 관리했다. 페이지에서 전체 상태를 관리하고 컴포넌트로 프로퍼티를 넘겨주는 방식으로 개발했다. 컴포넌트가 세분화 되고 자식의 자식의 자식의… 컴포넌트가 생기면서 prop drilling 문제가 생겼다. 오직 데이터를 전달하는 역할만 하는 컴포넌트가 많아졌다. 정확히 어느 컴포넌트에서 해당 데이터가 사용되는지 확인하는데 어려움이 있었다. 이를 해결하기 위해 Redux Toolkit을 도입했다. Redux Toolkit은 Redux에서 만든 공식 라이브러리로 Redux 작업을 단순화하고 더 쉽게 작성할 수 있도록 한다. Toolkit 내용은 뒤로 하고 Redux 동작 원리와 키워드에 대해 정리해보려한다.
Redux를 사용하고 싶다면 Redux Toolkit을 사용하자!
Redux #
Redux는 전역 state를 관리하는 라이브러리이다. state를 컴포넌트에 종속시키지 않고 외부에 store를 두어 관리한다. 이를 통해 모든 컴포넌트들은 트리 위치에 상관없이 state에 접근하거나 작업을 할 수 있다. 즉 state 관리와 관련된 개념을 정의하고 view와 분리한다.
state를 중앙에 집중
Redux는 단방향 데이터 흐름 구조를 사용한다. 간단하게 흐름을 설명하면 다음과 같다.
- State는 특정 시점의 앱 상태를 설명하고 UI는 해당 상태를 기반으로 렌더링된다.
- 사용자 버튼 클릭과 같이 이벤트가 발생하면 발생한 상황에 따라 상태가 업데이트 된다.
- UI는 새 상태를 기반으로 다시 렌더링 된다.
위의 흐름을 인지하고 Redux의 용어 개념과 함께 Redux의 동작 방식을 정리하려 한다.
Store #
애플리케이션 state를 가지고 있는 객체이다. Redux 앱에는 단 하나의 store만 있어야 한다. 하나의 store에서 state들이 객체 형식으로 저장된다.
Action #
상태를 업데이트하는 정보가 담긴 객체이다. store에 데이터를 넣는 유일한 방법!
Action은 타입(type)과 데이터(Toolkit에서는 payload)를 가지고 있다.
- type: 상수로 정의되고 다른 모듈에서 가져올 수 있다. 문자열로 정의하는 것이 좋다.
- payload: 데이터를 담는다. 숫자, 문자열 또는 객체일 수 있다.
// action 객체 예시
{type: 'todos/todoAdded', payload: todoText}
{type: 'todos/todoToggled', payload: todoId}
{type: 'todos/colorSelected', payload: {todoId, color}}
Reducer #
현재 state와 action을 받아 새 state를 반환하는 함수이다.
type Reducer<S, A> = (state: S, action: A) => S
const initialState = { value: 0 }
function counterReducer(state = initialState, action) {
switch (action.type) {
case 'counter/increment': {
return {
...state,
value: state.value + 1,
}
}
default:
return state
}
}
Reducer는 다음과 같은 규칙을 따라야 한다.
- state와 action에 기반해 새 state를 생성해야한다.
- 기존 state를 수정할 수 없다. 불변성!
- side effect를 발생하는 작업을 하면 안된다. (비동기 로직 등)
Reducer가 외부 변수에 의존하거나 무작위로 동작하면 어떤 일이 발생할지 알 수 없기 때문에 코드를 예측 가능하게 만드는 것이 중요하다.
불변성을 따르지 않을 경우 객체 업데이트를 인지하지 못해 UI 렌더링에 버그가 생길 수 있다. 원본을 변경하는 대신 새 복사본을 반환하는 작업을 수행해 불변성을 지켜야 한다. 객체 참조
https://daveceddia.com/react-redux-immutability-guide/
// ❌ Illegal - by default, this will mutate the state!
state.value = 123
// ✅ This is safe, because we made a copy
return {
...state,
value: 123,
}
여기서 Redux Toolkit의 장점이 나오는데, Toolkit은 불변성을 지켜주어 업데이트 규칙에 대한 생각을 덜어준다.
Dispatch #
dispatch 함수는 action을 파라미터로 전달한다. 호출을 해서 전달하면 store에서 reducer 함수를 실행해 해당 action을 처리하는 로직이 있다면 참고하여 새 state를 생성한다. 기본 dispatch 함수는 반드시 동기적으로 store의 reducer에 action을 보내야한다.
각각의 용어에서 보면 Redux의 세 가지 원칙을 알 수 있다.
- 애플리케이션의 전역 state는 단일 store내의 객체 트리에 저장된다.
- state를 변경하는 유일한 방법은 action이다.
- reducer는 순수 함수이다. 그저 이전 상태와 액션을 받아 다음 상태를 반환해야 한다.
Redux의 전체적인 흐름 #
- 초기 설정
- Store는 root reducer를 사용하여 생성된다.
- store는 root reducer를 호출해 초기 state를 저장한다.
- UI는 처음 렌더링되면 state를 참조해 렌더링할 항목을 결정한다. 향후 store 업데이트를 구독해 상태 변경을 알 수 있다.
- 업데이트
- 이벤트가 발생하면 store에 action을 전달한다.
- reducer는 이전 state와 action을 참조해 새 state를 저장한다.
- store는 state를 구독한 UI에 업데이트 되었음을 알린다.
- 각 UI는 새로운 데이터로 다시 렌더링되고 표시되는 내용을 업데이트한다.
React Redux #
react-redux를 설치한다
npm install react-redux
// yarn을 사용한다면
yarn add react-redux
React App을 Provider
로 감싸준다
Provider는 store를 앱의 모든 컴포넌트에서 사용할 수 있도록 해준다.
import React from 'react'
import ReactDOM from 'react-dom/client'
import { Provider } from 'react-redux'
import store from './store'
import App from './App'
// As of React 18
const root = ReactDOM.createRoot(document.getElementById('root'))
root.render(
<Provider store={store}>
<App />
</Provider>
)
React 요소가 Redux와 상호 작용할 수 있도록 hook을 제공한다
- useSelector: store의 state를 읽을 수 있고, 업데이트를 구독한다.
- useDispatch: store의 dispatch 메소드를 반환해 action을 전달할 수 있게 해준다.
export function Counter() {
const count = useSelector(selectCount)
const dispatch = useDispatch()
return (
<div>
<div className={styles.row}>
<button
className={styles.button}
aria-label='Increment value'
onClick={() => dispatch(increment())}
>
+
</button>
<span className={styles.value}>{count}</span>
<button
className={styles.button}
aria-label='Decrement value'
onClick={() => dispatch(decrement())}
>
-
</button>
</div>
</div>
)
}