💡 이 챕터에서 배우는 내용
- 전형적인 React + Redux 앱의 구조
- Redux DevTools 확장 프로그램에서 상태 변화를 보는 방법
{
counter: {
value: 0
}
}
+
버튼을 누르고 Redux DevTools의 "Diff" 탭을 보면 value 값이 1로 증가한 것을 볼 수 있습니다.+
버튼을 누르면 "counter/increment" 타입의 액션이 스토어에게 디스패치됩니다.state.counter.value
필드는 0에서 1로 변화합니다.+
버튼을 다시 누르면 값이 2가 됩니다.-
버튼을 한 번 누르면 값이 1이 됩니다.Add Amount
버튼을 누르면 값이 3이 됩니다.Add Async
버튼을 누르면 프로그레스 바가 채워지고 몇 초 후에 값이 6이 됩니다.state.counter.value
필드가 3에서 6으로 변경된 것을 볼 수 있습니다. <Counter>
컴포넌트로부터 액션을 디스패치하는 코드입니다./src
index.js
: 앱의 시작 부분App.js
: 리액트 컴포넌트의 최상위 레벨/app
store.js
: 리덕스 스토어 인스턴스 생성/features
/counter
Counter.js
: 카운터 기능의 UI를 보여주는 리액트 컴포넌트counterSlice.js
: 카운터 기능의 리덕스 로직// app/store.js
import { configureStore } from '@reduxjs/toolkit'
import counterReducer from '../features/counter/counterSlice'
export default configureStore({
reducer: {
counter: counterReducer
}
})
configureStore
configureStore
함수를 사용하면 리덕스 스토어가 생성됩니다. configureStore
은 전달하려는 리듀서를 인자로 요청합니다.configureStore
을 호출하면 객체의 모든 리듀서들을 전달할 수 있습니다. 객체의 키 이름은 최후의 상태 값의 키들로 정의됩니다.counterReducer
counterReducer
함수는 카운터 로직의 리듀서 함수를 내보내는 features/counter/counterSlice
파일에서 임포트합니다. counterReducer
는 스토어를 생성하면 포함시킬 수 있습니다.{counter: counterReducer}
state.counter
을 원한다는 의미입니다.state.counter
을 업데이트할 지 여부와 방법을 결정하는 역할을 counterReducer
함수가 담당하기를 원한다는 의미입니다.(1) Redux Slices
slice는 리덕스 리듀서 로직과 앱의 하나의 기능을 담당하는 액션의 집합입니다.
슬라이스라는 이름은 최상위 리덕스 상태 객체를 여러 개의 상태 "슬라이스"로 나누는 데에서 유래하였습니다.
예를 들어, 블로그 앱의 스토어 코드를 봐봅시다.
import { configureStore } from '@reduxjs/toolkit'
import usersReducer from '../features/users/usersSlice'
import postsReducer from '../features/posts/postsSlice'
import commentsReducer from '../features/comments/commnetsSlice'
export default configureStore({
reducer: {
users: usersReducer,
posts: postsReducer,
comments: commentsReducer
}
})
state.users
, state.posts
, state.comments
는 리덕스 상태의 분리된 "슬라이스" 입니다.
usersReducer
는 state.users
슬라이스가 업데이트하는 것에 반응하기 때문에, usersReducer
를 "슬라이스 리듀서" 함수라고 부릅니다.
counterReducer
함수가 features/counter/counterSlice.js
에서 온다는 것을 배웠습니다. 지금부터 그 파일에 무엇이 있는지 자세히 알아봅시다.// features/counter/counterSlice.js
import { createSlice } from '@reduxjs/toolkit'
export const counterSlice = createSlice({
name: 'counter',
initialValue: {
value: 0
},
reducers: {
increment: state => {
// 리덕스 툴킷을 사용하면 "변형" 로직 작성 가능
// 실제로 상태 변형 X
// 왜냐하면 "초안 상태"의 변경을 감지하고 해당 변경을 기반으로
// 새로운 불변의 상태를 생산하는 이머 라이브러리(Immer library)를 사용하기 때문
state.value += 1
},
decrement: state => {
state.value -= 1
},
incrementByAmount: (state, action) => {
state.value += action.payload
}
}
})
export const { increment, decrement, incrementByAmount } = counterSlice.actions
export default counterSlice.reducer
{type: "counter/increment"}
{type: "counter/decrement"}
{type: "counter/incrementByAmount"}
createSlice
라는 함수를 가지고 있는데, 이 함수는 액션 타입 문자열, 액션 생성자 함수, 액션 객체를 생성해줍니다. 우리는 이 슬라이스에 이름을 붙여주고 리듀서 함수를 가지고 있는 객체를 작성하면(counterSlice
) 자동으로 상응하는 액션 코드를 생성해 줍니다. name
옵션의 문자열은 각각의 액션 타입의 첫 번째 부분에 사용되고 각각의 리듀서 함수의 키 이름은 두 번째 부분에서 사용됩니다. "counter"
이름 + "increment"
리듀서 함수는 {type: "counter/increment"}
의 액션 타입을 생성하였습니다.name
필드 이외에도 createSlice
는 처음 호출될 때 상태가 있도록 리듀서 초기 상태값을 전달해야 합니다. 위에서는 0부터 시작하는 value
필드를 가진 객체를 제공하였습니다.increment
, decrement
, incrementByAmount
) 이 함수들은 다양한 버튼들을 클릭할 때 디스패치되는 세 개의 액션 타입들과 상응합니다.createSlice
는 자동으로 우리가 작성한 리듀서 함수와 같은 이름의 액션 생성자를 생성합니다. 우리는 그 중 하나를 호출하고 어떤 값을 반환하는지 확인할 수 있습니다.console.log(counterSlice.action.increment())
// {type: "counter/increment"}
createSlice
는 모든 액션 타입들에 응답하는 방법을 아는 슬라이스 리듀서 함수 또한 생성합니다.const newState = counterSlice.reducer(
{ value: 10 },
counterSlice.action.increment()
)
console.log(newState)
// {value: 11}
state
와 action
인자에 기반한 새로운 상태 값만 계산해야 합니다. state
를 수정하면 안됩니다. 대신에 이미 존재하는 state
를 복사하고 복사한 값을 변경함으로써 불변하는 업데이트를 만들어야 합니다.// ❌ Illegal
state.value = 123
// ✅ 안전한 방법
return {
...state,
value: 123
}
createSlice
함수는 더 쉽게 불변 업데이트를 작성할 수 있도록 도와줍니다.createSlice
는 Immer라고 불리는 라이브러리를 사용합니다.Proxy
라고 불리는 특별한 자바스크립트 툴을 사용하고, 래핑된 데이터를 "변동"하는 코드를 작성할 수 있도록 해줍니다.function handewrittenReducer(state, action) {
return {
...state,
first: {
...state.first,
second: {
...state.first.second,
[action.someId]: {
...state.first.second[action.someId],
fourth: action.someValue
}
}
}
}
}
function reducerWithImmer(state, action) {
state.first.second[action.someId].fourth = action.someValue
}
createSlice
와 createReducer
에서만 "변동" 로직을 작성할 수 있습니다. 왜냐하면 내부에서 Immer를 사용하기 때문입니다. 리듀서에서 Immer 없이 변동 로직을 작성한다면 상태값을 변동시킬 것이고 결국 버그가 발생하게 됩니다.counter slice의 실제 리듀서
// features/counter/counterSlice.js
export const counterSlice = createSlice({
name: 'counter',
initialState: {
value: 0
},
reducers: {
increment: state => {
state.value += 1
},
decrement: state => {
state.value -= 1
},
incrementByAmount: (state, action) => {
state.value += action.payload
}
}
})
increment
increment
리듀서는 state.value
로 항상 1을 더합니다. 왜냐하면 Immer는 초기 state
객체의 변경 사항을 알고 있으므로 여기서 아무것도 반환할 필요가 없기 때문입니다.decrement
increment
리듀서와 동일하게 decrement
리듀서는 1을 뺍니다.action
객체를 볼 필요가 없습니다. 어쨋든 전달되지만 필요하지 않기 때문에 리듀서에 action
을 파라미터로 선언하지 않아도 됩니다.incrementByAmount
리듀서에서 알아야 할 것이 있습니다. -> 카운터 값에 얼마나 추가되어야 하는지.state
와 action
인자를 가진 리듀서를 선언합니다.action.payload
필드에 넣어져야 state.value
에 더할 수 있습니다.thunk는 비동기 로직을 가지고 있는 리덕스 함수의 특별한 종류입니다.
Thunks는 2가지 함수를 사용하여 작성할 수 있습니다.
dispatch
와 getState
를 인자로 받습니다.counterSlice
에서 내보낸 다음 함수는 thunk 액션 생성자 예제입니다.
// features/counter/counterSlice.js
// 아래의 함수는 thunk라고 불리고 비동기 로직을 수행
// 일반 액션처럼 디스패치 가능: 'dispatch(incrementAsync(10))'
// 첫 번째 인자로 'dispatch' 함수와 thunk 호출
// 그 다음 비동기 코드 실행 -> 다른 액션들 디스패치
export const incrementAsync = amount => dispatch => {
setTimeout(() => {
dispatch(incrementByAmount(amount))
}, 1000)
}
store.dispatch(incrementAsnc(5))
redux-thunk
미들웨어(리덕스용 플러그인 유형)가 리덕스 스토어가 생성될 때 스토어에 추가되는 과정이 필요합니다. 다행히도 리덕스 툴킷의 configureStore
함수는 자동으로 이를 설정해 두었기 때문에 thunks를 그냥 사용할 수 있습니다.서버로부터 데이터를 패치하기 위해 AJAX를 호출할 때, thunk에 호출을 둘 수 있습니다. 어떻게 정의하는 지 알아보기 위해 예제를 살펴봅시다.
// features/counter/counterSlice.js
// "thunk 생성자" 함수 바깥
const fetchUserById = userId => {
// "thunk 함수" 내부
return async (dispatch, getState) => {
try {
// thunk에서 비동기 호출
const user = await userAPI.fetchById(userId)
// 응답을 받을 때 액션 디스패치
dispatch(userLoaded(user))
} catch (err) {
// 에러가 발생하면 여기에서 처리
}
}
}
<Counter>
를 살펴보았습니다. React + Redux 앱은 <Counter>
컴포넌트와 비슷하지만 다른 점이 있습니다.먼저 Counter.js
컴포넌트 파일부터 살펴봅시다.
// features/counter/Counter.js
import React, { useState } from 'react'
import { useSelector, useDispatch } from 'react-redux'
import {
decrement,
increment,
incrementByAmount,
incrementAsync,
selectCount
} from './counterSlice'
import styles from './Counter.module.css'
export function Counter() {
const count = useSelector(selectCount)
const dispatch = useDispatch()
const [incrementAmount, setIncrementAmount] = useState('2')
return (
<div>
<div className={styles.row}>
<button
className={styles.button}
arial-label="Increment value"
onClick={() => dispatch(increment())}
>
+
</button>
<span className={styles.value}>{count}</span>
<button
className={styles.button}
arial-label="Decrement value"
onClick={() => dispatch(decrement())}
>
-
</button>
</div>
{/* omit additional rendering output here */}
</div>
)
}
useState
훅으로 데이터를 저장하는 Counter
컴포넌트 함수가 있습니다.count
라고 불리는 변수가 있지만 ustState
훅에서 발생하지는 않습니다.리액트는 useState
와 useEffect
같이 몇몇의 빌트인 훅을 가지고 있지만 다른 라이브러리들은 커스텀 로직을 빌드하기 위해 리액트 훅을 사용하는 자체적인 커스텀 훅을 만들 수 있습니다.
React-Redux 라이브러리는 리액트 컴포넌트가 리덕스 스토어와 상호작용하도록 하는 일련의 커스텀 훅들을 가지고 있습니다.
useSelector
로 데이터 읽기
첫 번째로, useSelector
훅은 컴포넌트가 리덕스 스토어 상태에서 필요한 데이터 조각을 추출할 수 있습니다.
이전에 state
를 인자로 취급하고 상태 값의 일부를 반환하는 "선택자" 함수에 대해 알아보았습니다.
counterSlice.js
는 밑에서 이 선택자 함수를 가지고 있습니다.
// features/counter/counterSlice.js
// 아래의 함수는 선택자라고 불리는 값을 상태로부터 선택할 수 있도록 합니다.
// 선택자는 슬라이스 파일 대신에 사용되는 곳인 인라인에서 정의될 수도 있습니다.
// 예를 들어, 'useSelector((state) => state.counter.value)'
export const selectCount = state => state.counter.value;
리덕스 스토어에 접근할 때 현재의 카운터 값을 검색할 수 있습니다.
const count = selectCount(store.getState())
console.log(count) // 0
우리의 컴포넌트들은 리덕스 스토어에 직접 이야기 할 수 없기 때문에 컴포넌트 파일에서 임포트해 올 수 없습니다. 하지만 useSelector
는 우리를 위해 뒤에서 리덕스 스토어에게 이야기 하는 일을 처리합니다. 만약 선택자 함수를 전달하면 우리를 위해 someSelector(store.getState())
를 호출하고 결과값을 반환합니다.
그래서 아래의 코드로 현재의 스토어 카운터를 받아올 수 있습니다.
const count = useSelector(selectCount)
또한 이미 내보낸 선택자만 사용하지 않아도 됩니다. 예를 들어, useSelector
로 선택자 함수를 인라인 인자로 작성할 수 있습니다.
const countPlusTwo = useSelector(state => state.counter.value)
액션이 디스패치되고 리덕스 스토어가 업데이트될 때마다 useSelector
는 선택자 함수를 재실행합니다. 만약 선택자가 지난번과 다른 값을 반환했다면 useSelector
는 새로운 값으로 컴포넌트를 리렌더링합니다.
useDispatch
로 액션 디스패치
비슷하게 리덕스 스토어에 접근할 때 store.dispatch(increment())
같이 액션 생성자를 사용하여 액션을 디스패치할 수 있습니다. 스토어 자체에 접근할 수 없기 때문에 dispatch
메서드에서만 접근할 수 있는 방법이 필요합니다.
useDispatch
훅은 dispatch
메서드에 접근하고 리덕스 스토어로부터 실제 dispatch
메서드를 제공합니다.
const dispatch = useDispatch()
거기에서 버튼을 누르는 것처럼 사용자가 무엇인가를 하면 액션을 디스패치할 수 있습니다.
// features/counter/Counter.js
<button
className={styles.button}
arial-label="Increment value"
onClick={() => dispatch(increment())}
>
+
</button>
지금쯤이면 "항상 내 앱의 모든 상태를 리덕스 스토어에 넣어야 하는가?"라는 의문이 생길 것입니다.
답은 '아니다.'입니다. 앱 전반적으로 필요한 전역 상태는 리덕스 스토어에 있어야 합니다. 하지만 한 곳에서만 필요한 상태는 컴포넌트 상태에 있어야 합니다.
예를 들어, 카운터에 값을 추가하기 위해 사용자가 다음 숫자를 입력해야 하는 텍스트 박스가 있다고 합시다.
// features/counter/Counter.js
const [incrementAmount, setIncrementAmount] = useState('2')
//later
return (
<div className={styles.row}>
<input
className={styles.textbox}
aria-label="Set increment amount"
value={incrementAmount}
onChange={e => setIncrementAmount(e.target.value)}
/>
<button
className={styles.button}
onClick={() => dispatch(incrementByAmount(Number(incrementAmount) || 0))}
>
Add Amount
</button>
<button
className={styles.asyncButton}
onClick={() => dispatch(IncrementAsync(Number(incrementAmount) || 0))}
>
Add Async
</button>
</div>
)
onChange
핸들러에서 액션을 디스패치하고 리듀서에 저장함으로써 숫자 문자열을 리덕스 스토어에 저장합니다. 하지만 이렇게 하면 어떠한 이익도 없습니다. <Counter>
컴포넌트 안에서 텍스트 문자열이 사용되는 곳은 여기밖에 없습니다.(당연히 이 예제의 다른 컴포넌트인 <App>
이 있습니다. 하지만 많은 컴포넌트를 가진 큰 애플리케이션이라면, <Counter>
에서만 입력값을 처리합니다.)<Counter>
컴포넌트의 useState
훅에서 해당 값을 유지하는 것이 더 합리적입니다.counterSlice.js
의 incrementAsync
thunk를 기억하고 계시나요? 다른 일반적인 액션 생성자를 디스패치하는 것과 똑같은 방법을 사용하고 있다는 것을 주의하세요. 이 컴포넌트는 일반 액션을 디스패치하는지 비동기 로직을 시작하는지 상관하지 않습니다. 이 컴포넌트는 우리가 버튼을 클릭할 때 어떤 것을 디스패치한다는 것만 알고 있습니다.useSelector
와 useDispatch
훅을 사용하는 것을 보았습니다. 하지만 스토어를 임포트하지 않는다면 훅들이 어떤 리덕스 스토어와 이야기해야 하는지 알 수 있을까요?우리는 카운터 애플리케이션의 모든 부분을 보았습니다.이제 이 애플리케이션의 처음 부분으로 돌아가 마지막 남은 퍼즐이 어떻게 맞춰지는 지 보아야 할 시간입니다.
// index.js
import React from 'react'
import ReactDOM from 'react-dom'
import './index.css'
import App from './App'
import store from './app/store'
import { Provider } from 'react-redux'
import * as serviceWorkerfrom './serviceWorker'
ReactDOM.render(
<Provider sotre={store}>
<App />
</Provider>,
document.getElementById('root')
)
<App>
렌더링을 시작해달라고 말하기 위해 항상 ReactDOM.render(<App />)
을 호출해야 합니다. useSelector
같은 훅들이 잘 동작하기 위해서는 리덕스 스토어를 뒤에서 전달하여 접근할 수 있도록 <Provider>
라고 불리는 컴포넌트를 사용해야 합니다.app/store.js
에 이미 생성해 두었기 때문에 여기에서 임포트 해왔습니다. <Provider>
컴포넌트는 전제 <App>
주위에 두고 스토어에 전달합니다. (<Provider store={store}>
)useSelector
이나 useDispatch
를 호출하는 어떠한 리액트 컴포넌트라도 <Provider>
에게 주었다고 리덕스 스토어와 이야기할 수 있을 것입니다.configureStore
API를 사용하여 리덕스 스토어를 생성할 수 있습니다.configureStore
는 reducer
함수를 기명 인자로 받아들입니다.configureStore
는 자동으로 스토어를 제일 좋은 기본으로 세팅합니다.createSlice
API는 액션 생성자와 개발자가 제공한 각각의 리듀서 함수의 액션 타입을 생성합니다.state
와 action
인자를 기반으로 한 새로운 상태 값만 계산해야 합니다.createSlice
API는 "돌연변이" 불변의 업데이트를 허용하기 위해 Immer를 사용합니다.dispatch
와 getState
를 인자로 받습니다.redux-thunk
미들웨어를 실행합니다.<Provider store={store}>
로 앱을 감싸면 모든 컴포넌트에서 스토어를 사용할 수 있습니다.출처
🔗 공식 문서: https://ko.redux.js.org/tutorials/essentials/part-2-app-structure#component-state-and-forms
🔗 Github: https://github.com/chaevivin/Front-end_study/blob/main/Redux/Redux_App_Structure.md
더 자세하게 정리되어 있고, 번역된 문서를 보고싶다면 Github에서 제가 작성한 문서를 확인하세요.