728x90
728x90
리덕스(Redux), 리덕스 툴킷(Redux Toolkit)
들어가며
- 리액트(React.js)의 전역 상태 관리 라이브러리 중의 하나인 리덕스(Redux)와 리덕스 툴킷(Redux Toolkit)에 대해 정리해본다.
리덕스(Redux)
개념
- 자바스크립트 애플리케이션에서 상태 관리를 위해 사용되는 상태 컨테이너
- 애플리케이션의 상태를 중앙에서 관리함으로써 상태 변화를 쉽게 추적하고, 디버깅을 단순화하며, 다양한 컴포넌트 간에 상태를 공유하는 것을 쉽게 해준다.
- 리덕스를 사용하기 위해서는 아래의 명령을 실행하여 관련 패키지를 설치해준다.
$ yarn add redux # npm install redux
$ yarn add react-redux # npm install react-redux
리덕스(Redux)는 리액트(React.js) 뿐만 아니라 다양한 자바스크립트 패키지에서도 사용할 수 있다.
액션(Action)
- 애플리케이션에서 일어나는 사건을 설명하는 객체
- @type@ 속성을 필수로 가지며, 상태 변경을 트리거하는 역할을 한다.
- 예) @{ type: 'INCREMENT' }@, @{ type: 'ADD_TODO', text: 'Learn Redux' }@
리듀서(Reducer)
- 액션을 처리하여 새로운 상태를 반환하는 함수
- 이전 상태와 액션 객체를 인자로 받아 새로운 상태 객체를 반환한다.
- 순수 함수여야 하며, 입력이 같으면 출력도 항상 같아야 한다.
- 순수 함수(Pure Function)
- 동일한 인자가 주어졌을 때 항상 동일한 결과를 변환
- 함수의 실행이 외부 상태에 의존하지 않음
- 외부 상태를 변경하지 않는 함수
- 순수 함수(Pure Function)
function counter(state = 0, action) {
switch (action.type) {
case 'INCREMENT':
return state + 1;
case 'DECREMENT':
return state - 1;
default:
return state;
}
}
스토어(Store)
- 애플리케이션의 상태를 담고 있는 객체
- @createStore@ 함수를 사용하여 스토어를 생성한다.
- 스토어는 3가지 메소드를 가진다:
- @getState()@: 현재 상태 반환합
- @dispatch(action)@: 상태를 변경하는 액션 보내기
- @subscribe(listener)@: 상태가 변경될 때마다 호출되는 리스너를 등록하기
미들웨어(Middleware)
- 액션이 리듀서에 도달하기 전에 처리할 수 있는 기능을 제공한다.
- 로깅, 에러 보고, 비동기 작업 처리 등에 사용된다.
사용 방법
- 예제 코드와 함께 Redux를 사용하는 방법을 간단하게 정리해본다.
./src/store/index.js
- 프로젝트의 @src@ 폴더 내부에 @store@ 폴더를 생성한 후, 안에 @index.js@ 파일을 생성한다.
import { createStore } from 'redux';
const counterReducer = (state = initialState, action) => {
if (action.type === "increment") {
return {
counter: state.counter + 1,
showCounter: state.showCounter,
};
}
if (action.type === "increase") {
return {
counter: state.counter + action.amount,
showCounter: state.showCounter,
};
}
if (action.type === "decrement") {
return {
counter: state.counter - 1,
showCounter: state.showCounter,
};
}
if (action.type === "toggle") {
return {
showCounter: !state.showCounter,
counter: state.counter,
};
}
return state;
};
// 중앙 스토어 생성 및 리듀서 함수 연결
const store = createStore(counterReducer);
export default store;
./src/index.js
- 생성한 @store.js@ 파일에서 @store@를 불러온다.
- @react-redux@ 패키지에서 @Provider@ 컴포넌트를 불러온다.
- @Provider@ 컴포넌트로 @<App />@ 컴포넌트를 감싸준다.
- @store@ prop의 값을 @store.js@에서 불러온 @store@로 설정해준다.
import React from "react";
import ReactDOM from "react-dom/client";
import "./index.css";
import App from "./App";
// Provider, store 불러오기
import { Provider } from "react-redux";
import store from "./store/index";
const root = ReactDOM.createRoot(document.getElementById("root"));
root.render(
<Provider store={store}> {/* <App/>을 <Provider>로 감싸준 후, store prop 설정 *}
<App />
</Provider>
);
./src/components/counter.js
- 실제 컴포넌트에서 사용하는 예제이다.
- @useSelecter@와 전역 상태 보관소(@store.js@)의 값에 접근한다.
- @useDispatch@를 사용하여 컴포넌트에서 전역 상태 보관소(@store.js@)에 액션(Action)을 전달하여 상태를 변경한다.
import { useSelector, useDispatch } from "react-redux";
import classes from "./Counter.module.css";
const Counter = () => {
const dispatch = useDispatch();
const counter = useSelector((state) => state.counter);
const show = useSelector((state) => state.showCounter);
const incrementHandler = () => {
dispatch({ type: "increment" });
};
const increaseHandler = () => {
dispatch({ type: "increase", amount: 5 });
};
const decrementHandler = () => {
dispatch({ type: "decrement" });
};
const toggleCounterHandler = () => {
dispatch({ type: "toggle" });
};
return (
<main className={classes.counter}>
<h1>Redux Counter</h1>
{show && <div className={classes.value}>{counter}</div>}
<div>
<button onClick={incrementHandler}>Increment</button>
<button onClick={increaseHandler}>Increase by 5</button>
<button onClick={decrementHandler}>Decrement</button>
</div>
<button onClick={toggleCounterHandler}>Toggle Counter</button>
</main>
);
};
export default Counter;
사용 시 주의 사항
- 리덕스를 사용할 때 절대로 다음과 같이 변수 자체를 변형(Mutation)시키지 않는다.
if (action.type === "increment") {
state.counter++;
return state;
}
- 그 대신에 다음과 같이 객체로 오버라이딩(Overriding)한다.
if (action.type === "increment") {
return {
counter: state.counter + 1,
showCounter: state.showCounter,
};
}
상태 업데이트 시 일부 상태만 설정하면 나머지 상태는 undefined로 처리되어 예상치 못한 결과를 초래할 수 있다. 따라서 상태 업데이트를 할 때는 모든 기존 상태를 포함하도록 새로운 상태 객체를 반환해야 한다.
리덕스 툴킷(Redux Toolkit, RTK)
개념
- 리덕스를 더 쉽고 효율적으로 사용하기 위해 리덕스 툴킷(Redux Toolkit)을 사용할 수 있다.
$ npm install @reduxjs/toolkit # yarn add @reduxjs/toolkit
$ npm install react-redux # yarn add react-redux
⇒ 리덕스 툴킷은 아래와 같은 라이브러리로 구성되어 있다.
✅ @redux@ : 상태 관리
✅ @immer@ : 상태 불변성 관리
✅ @redux-thunk@ : 비동기 액션 처리
✅ @reselect@ : 셀렉터 최적화
- 리덕스 툴킷은 리덕스의 일반적인 보일러플레이트(반복되는) 코드를 줄이고, 직관적이며 간결한 코드 작성을 가능하게 한다.
- 기본 리덕스를 사용할 때는 많은 설정과 보일러플레이트 코드가 필요하다.
- 예를 들어, 액션 생성자, 리듀서, 스토어 설정 등을 별도로 작성해야 하는데 리덕스 툴킷은 이러한 설정을 단순화하여 개발자의 작업을 줄여준다.
- 기본 리덕스를 사용할 때는 많은 설정과 보일러플레이트 코드가 필요하다.
- 리덕스 툴킷은 @configureStore@ 함수를 제공하여 스토어를 쉽게 설정할 수 있다.
- 이 함수는 기본적인 미들웨어 설정과 디버깅 도구 설정을 자동으로 처리해준다.
import { configureStore } from '@reduxjs/toolkit';
import rootReducer from './reducers';
const store = configureStore({
reducer: rootReducer,
});
- 리덕스 툴킷의 @createSlice@ 함수는 액션 생성자와 리듀서를 한 번에 생성할 수 있게 해준다.
- 리덕스의 핵심 개념을 더 직관적이고 간결하게 구현할 수 있다.
import { createSlice } from '@reduxjs/toolkit';
const counterSlice = createSlice({
name: 'counter',
initialState: 0,
reducers: {
increment: (state) => state + 1,
decrement: (state) => state - 1,
},
});
export const { increment, decrement } = counterSlice.actions;
export default counterSlice.reducer;
- 리덕스 툴킷은 리덕스 개발자 도구(Redux DevTools)와 통합하여 상태 변화를 시각적으로 추적하고 디버깅하는 데 큰 도움을 준다.
- 리덕스 툴킷은 타입 스크립트(TypeScript)와의 호환성을 강화하여, 타입 안전성을 유지하면서도 리덕스를 간편하게 사용할 수 있게 해준다.
- 리덕스 툴킷은 @immer@를 사용하여 불변성 관리를 자동으로 처리해주며, 기본적으로 Redux Thunk 미들웨어를 포함한다.
- 이러한 설정들은 개발자가 직접 최적화할 필요 없이 기본적으로 제공되어 개발 속도를 빠르게 해준다.
Redux Toolkit은 Redux를 더욱 편리하게 사용할 수 있도록 만들어진 도구로, Redux에서 자주 사용되는 기능들을 포함한 '배터리가 장착된' Redux라고 할 수 있다.
Immer와 상태 불변성(Immutability)
- 리덕스를 사용할 때 절대로 변수 자체를 변형(Mutation)시키면 안된다.
- 하지만, 리덕스 툴킷에서는 @createSlice@를 사용할 때 @Immer@라는 라이브러리가 자동으로 포함되어 있어, 불변성을 직접 관리할 필요 없이 더 직관적인 방식으로 상태를 변경할 수 있다.
- 즉, 상태를 직접 변형하는 것처럼 보이는 코드를 작성해도 @Immer@가 내부적으로 상태를 불변성으로 유지해 준다.
const counterSlice = createSlice({
name: 'counter',
initialState: { counter: 0, showCounter: true },
reducers: {
increment(state) {
state.counter++;
}
}
});
⇒ @state.counter++@는 상태를 직접 변형하는 것처럼 보이지만, @Immer@가 이 코드를 감싸서 실제로는 불변성을 유지한 상태로 새로운 상태를 반환하게 해준다.
리덕스 툴킷에서는 상태를 명시적으로 오버라이딩(Overriding)하는 방식도 사용할 수 있지만, Immer 라이브러리 때문에 상태를 변경하는 코드를 작성할 수도 있다.
createAsyncThunk() 함수
- 리덕스 툴킷은 비동기 작업을 간편하게 처리할 수 있는 @createAsyncThunk@ 함수를 제공한다.
- 비동기 작업의 상태 관리(로딩(@pending@), 성공(@fulfilled@), 실패(@rejected@) 등)를 쉽게 처리할 수 있다.
import { createAsyncThunk, createSlice } from '@reduxjs/toolkit';
import axios from 'axios';
export const fetchUser = createAsyncThunk('users/fetchUser', async (userId) => {
const response = await axios.get(`/api/users/${userId}`);
return response.data;
});
const userSlice = createSlice({
name: 'user',
initialState: {
user: null,
loading: false,
error: null,
},
reducers: {},
extraReducers: (builder) => {
builder
.addCase(fetchUser.pending, (state) => {
state.loading = true;
})
.addCase(fetchUser.fulfilled, (state, action) => {
state.loading = false;
state.user = action.payload;
})
.addCase(fetchUser.rejected, (state, action) => {
state.loading = false;
state.error = action.error.message;
});
},
});
export default userSlice.reducer;
- 관련 내용은 아래의 글을 참고한다.
사용 방법
- 실제 제작했었던 예제 코드를 바탕으로 사용 방법을 정리해본다.
슬라이스 생성하기
./src/features/cart/cartSlice.jsx
- 슬라이스 파일을 생성해준다.
- @./src@ 경로에 @features@ 폴더를 생성한 후, 전역 상태를 관리할 항목의 범주에 따라 폴더(@cart@)와 슬라이스 파일(@cartSlice.jsx@)을 생성해준다.
- 슬라이스 파일(@cartSlice.jsx@)에 @createSlice@ 함수를 이용하여 슬라이스를 생성한다.
- 슬라이스 이름(@name@) : @'cart'@
- 상태 초기값(@initialState@) : @initialState@
- 리듀서(@reducers@) : @{ action: (state, { payload }) => {}, ..., action: (state, { payload }) => {} }@
- 리듀서 안에 액션(Action)들을 정의해준다.
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';
import axios from 'axios';
import { openModal } from '../modal/modalSlice';
const url = 'https://www.example-api.com/cart-project';
// 상태 초기값 설정하기
const initialState = {
cartItems: [],
amount: 0,
total: 0,
isLoading: true,
};
// API 통신 후 데이터 가져오기
export const getCartItems = createAsyncThunk(
'cart/getCartItems',
async (name, thunkAPI) => {
try {
// console.log(name);
// thunkAPI를 이용하여 다양한 정보를 얻을 수 있다. (state 등)
// console.log(thunkAPI);
// console.log(thunkAPI.getState());
// thunkAPI.dispatch(openModal());
const resp = await axios(url);
return resp.data;
} catch (error) {
return thunkAPI.rejectWithValue('something went wrong');
}
}
);
// 슬라이스 생성하기
const cartSlice = createSlice({
// (1) 슬라이스 이름 설정
name: 'cart',
// (2) 상태 초기값 설정
initialState,
// (3) 리듀서 설정
reducers: {
// 액션1 : 카트 비우기
clearCart: (state) => {
state.cartItems = [];
},
// 액션2 : 아이템 삭제하기
removeItem: (state, action) => {
const itemId = action.payload;
state.cartItems = state.cartItems.filter((item) => item.id !== itemId);
},
// 액션3 : 아이템 개수 증가시키기
// payload에는 dispatch를 수행하는 외부 컴포넌트에서 보내는 값이 들어있다.
increase: (state, { payload }) => {
const cartItem = state.cartItems.find((item) => item.id === payload.id);
cartItem.amount = cartItem.amount + 1;
},
// 액션4 : 아이템 개수 감소시키기
decrease: (state, { payload }) => {
const cartItem = state.cartItems.find((item) => item.id === payload.id);
cartItem.amount = cartItem.amount - 1;
},
// 액션5 : 전체 아이템 개수 및 가격 계산하기
calculateTotals: (state) => {
let amount = 0;
let total = 0;
state.cartItems.forEach((item) => {
amount += item.amount;
total += item.amount * item.price;
});
state.amount = amount;
state.total = total;
},
},
// (4) 추가 리듀서 설정
// RTK 2.0부터 객체 형태가 아닌, '빌더 콜백' 형태로 사용한다.
extraReducers: (builder) => {
builder
// pending 상태일 경우 (pending)
.addCase(getCartItems.pending, (state) => {
state.isLoading = true;
})
// 모두 불러와졌을 경우 (fulfilled)
.addCase(getCartItems.fulfilled, (state, action) => {
// console.log(action);
state.isLoading = false;
state.cartItems = action.payload; // 불러와진 데이터로 cartItem 업데이트
})
// 거절되었을 경우 (rejected)
.addCase(getCartItems.rejected, (state, action) => {
console.log(action);
state.isLoading = false;
});
},
});
// <1> 리듀서 내보내기
// - 기본 내보내기를 했기 때문에, 외부에서 불러올 때는 아무 이름으로 불러올 수 있다.
// 예) import cartReducer from './features/cart/cartSlice';
export default cartSlice.reducer;
// <2> 액션 내보내기
// 외부에서 사용할 수 있는 액션을 내보낸다.
export const { clearCart, removeItem, increase, decrease, calculateTotals } =
cartSlice.actions;
./src/features/modal/modalSlice.jsx
import { createSlice } from '@reduxjs/toolkit';
const initialState = {
isOpen: false,
};
const modalSlice = createSlice({
name: 'modal',
initialState,
reducers: {
// 액션1 : 모달 열기
openModal: (state, action) => {
state.isOpen = true;
},
// 액션2 : 모달 닫기
closeModal: (state, action) => {
state.isOpen = false;
},
},
});
// 리듀서 내보내기
export default modalSlice.reducer;
// 액션 내보내기
export const { openModal, closeModal } = modalSlice.actions;
스토어(Store) 생성하기
./src/store/index.js
- @./src@ 경로에 @store@ 폴더를 만들고, 안에 @index.js@ 파일을 생성한다.
- 그리고 안에서 위에서 생성했던 슬라이스 파일을 불러온 후, @configureStore()@를 이용하여 리듀서를 설정해준다.
- 이제 외부 컴포넌트에서에서 이 저장소를 통해 슬라이스의 값(@cart@, @modal@)에 접근할 수 있게 된다.
import { configureStore } from '@reduxjs/toolkit';
// (참고) export default로 내보내기 했기 때문에, import 할 때는 아무런 이름을 지정하여 가져올 수 있다.
import cartReducer from '../features/cart/cartSlice';
import modalReducer from '../features/modal/modalSlice';
export const store = configureStore({
reducer: {
// 외부에서 접근할 때 다음과 같이 사용한다.
// -> const { value } = useSelector((state) => state.cart);
cart: cartReducer,
modal: modalReducer,
},
});
최상위 경로의 파일(@main.jsx)@에 스토어 적용하기
./src/main.jsx
- 최상위 경로의 파일(@./src/main.jsx@)에 스토어(Store, @./src/store/index.js@)를 적용시켜준다.
- @react-redux@ 패키지에서 @Provider@ 컴포넌트를 불러온다.
- @./store/index.js@ 파일에서 @store@를 불러온다.
- 최상위 컴포넌트인 @<App />@ 컴포넌트를 @<Provider@ 컴포넌트로 감싸 준다.
- @store@ prop의 값을 @./store/index.js@ 파일에서 불러온 @store@로 설정해준다.
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import App from './App.jsx';
import './index.css';
// 리덕스 사용하기
import { Provider } from 'react-redux';
import { store } from './store/index.js';
createRoot(document.getElementById('root')).render(
<StrictMode>
<Provider store={store}>
<App />
</Provider>
</StrictMode>
);
사용하기
- 이제 전역 상태 관리를 위한 구성을 끝냈다.
- 전역 상태 변수와 액션에 접근하는 방법을 정리해본다.
전역 상태 변수에 접근하기
- @react-redux@ 패키지의 @useSelector@ 함수를 사용하여 전역 상태 변수에 접근할 수 있다.
import { CartIcon } from '../icons';
import { useSelector } from 'react-redux';
const Navbar = () => {
// 전역 상태 변수에 접근하기
const { amount } = useSelector((state) => state.cart);
return (
<nav>
<div className="nav-center">
<h3>Cart with Redux Toolkit</h3>
<div className="nav-container">
<CartIcon />
<div className="amount-container">
<p className="total-amount">{amount}</p>
</div>
</div>
</div>
</nav>
);
};
export default Navbar;
액션에 디스패치 하기
- @react-redux@ 패키지의 @useDispatch@ 함수를 이용하여 액션에 디스패치할 수 있다.
- 이때, 디스패치하기 위해 필요한 함수들을 슬라이스 파일에서 불러와준다.
- @import { removeItem, increase, decrease } from '../features/cart/cartSlice';@
- 이렇게 디스패치를 함으로써 전역적으로 상태를 변화시킬 수 있다.
import { useDispatch } from 'react-redux';
import { ChevronDown, ChevronUp } from '../icons';
import { removeItem, increase, decrease } from '../features/cart/cartSlice';
const CartItem = ({ id, img, title, price, amount }) => {
const dispatch = useDispatch();
return (
<article className="cart-item">
<img src={img} alt={title} />
<div>
<h4>{title}</h4>
<h4 className="item-price">${price}</h4>
{/* remove button */}
<button className="remove-btn" onClick={() => dispatch(removeItem(id))}>
remove
</button>
</div>
<div>
{/* increase amount */}
<button
className="amount-btn"
onClick={() => dispatch(increase({ id }))}
>
<ChevronUp />
</button>
{/* amount */}
<p className="amount">{amount}</p>
{/* decrease amount */}
<button
className="amount-btn"
onClick={() => {
if (amount === 1) {
dispatch(removeItem(id));
return;
}
dispatch(decrease({ id }));
}}
>
<ChevronDown />
</button>
</div>
</article>
);
};
export default CartItem;
프로젝트 구조 정리
/src
├─ /features ─ /cart/cartSlice.js, /modal/modalSlice → 슬라이스 파일 보관
├─ /store ─ index.js → 스토어 파일 보관
├─ /components → 전역 상태 변수 접근 및 액션 디스페치
├─ App.jsx
└─ index.jsx → 스토어 적용
사용 시 주의 사항
- RTK 2.0부터는 @createReducer@와 @createSlice.extraReducers@에서 객체(Object) 형태의 사용을 더 이상 지원하지 않는다.
// RTK 2.0 이전의 방식
const cartSlice = createSlice({
// ...
extraReducers: {
[getCartItems.pending]: (state) => {
state.isLoading = true;
},
[getCartItems.fulfilled]: (state, action) => {
console.log(action);
state.isLoading = false;
state.cartItems = action.payload;
},
[getCartItems.rejected]: (state) => {
state.isLoading = false;
},
},
}
- 빌더(Builder) 콜백 형태가 간결하며, 타입스크립트(TypeScript)와의 통합에서도 더 효과적이기 때문이다.
// RTK 2.0 이후의 방식
// -> 빌더 콜백 형태로 사용한다.
const cartSlice = createSlice({
// ...
extraReducers: (builder) => {
builder
.addCase(getCartItems.pending, (state) => {
state.isLoading = true;
})
.addCase(getCartItems.fulfilled, (state, action) => {
// console.log(action);
state.isLoading = false;
state.cartItems = action.payload;
})
.addCase(getCartItems.rejected, (state, action) => {
console.log(action);
state.isLoading = false;
});
},
}
함께 사용하면 좋은 것들
Redux DevTools
- 브라우저 확장 프로그램이다.
- 애플리케이션의 상태(@state@)를 쉽게 디버깅하고 추적할 수 있게 도와준다.
- 애플리케이션의 상태 변화, 액션(@action@)의 흐름, 상태 변화 전후의 차이를 실시간으로 확인할 수 있다.
참고 사이트
728x90
728x90
'Programming > React' 카테고리의 다른 글
[React.js] React Hook Form 라이브러리 (0) | 2024.11.23 |
---|---|
[React.js] 객체 표기법(Object Notation) 방식과 빌더 콜백 표기법(Builder Callback Notation) 방식 (0) | 2024.11.13 |
[React.js] .js 파일에서 Uncaught SyntaxError: Unexpected token '<' 오류 발생할 때 해결 방법 (Vite) (0) | 2024.11.13 |
[React.js] <Link> 컴포넌트와 <NavLink> 컴포넌트 비교 (React Router) (0) | 2024.11.13 |
[React.js] 모든 웹 브라우저에서 공통된 HTML 요소 스타일이 보여지도록 설정하는 방법 (normalize.css) (0) | 2024.11.07 |
[React.js] Recharts 라이브러리 (1) | 2024.11.06 |
[React.ts] PropsWithChildren (0) | 2024.11.05 |
[React.js] const Component vs. function Component (2) | 2024.11.04 |