Redux
는 현 시점 가장 높은 시장 점유율을 차지하고 있는 대표적인 JavaScript
상태 관리 라이브러리이다.
하지만 Redux
를 사용하면서 발생되는 다음과 같은 단점들은 이전부터 많은 Front-end
개발자의 고민이었다.
- 스토어 구성의 높은 복잡성
- 많은 양의 관련 패키지 및 사용 방법
- 보일러플레이트 (많은 상용구 코드의 필요성)
그래서 Redux
공식 개발팀에서는 위와 같은 문제를 해결하기 위해 Redux-Toolkit
이라는 도구를 개발했다.
Redux
로직을 효율적으로 사용할 수 있는 표준 방식
공식 문서에 따르면 Redux-Toolkit
은 기존 Redux
의 문제점을 보완하기 위해 개발된 도구이다.
즉, 효율적인 Redux
개발을 위해 Redux
공식 팀에서 개발한 공식적이고 독단적인 배터리 포함 도구 세트이다.
Redux Toolkit
의 특징은 다음과 같다.
Simple
: 스토어 설정, 리듀서 생성, 변경 불가능한 업데이트 로직 등과 같은 일반적인 사용 사례를 단순화하는 유틸리티가 포함되어 있다.
Opinionated
: 스토어 설정을 위한 좋은 기본값을 제공하며 가장 일반적으로 사용되는 Redux addon
이 내장되어 있다.
Powerful
: Immer
및 Autodux
와 같은 라이브러리에서 영감을 얻어 "변형" 로직으로 불변성 업데이트 논리를 작성하고 state
전체를 slice
로 자동으로 생성 할 수도 있다.
Effective
: 적은 코드로 많은 작업이 가능하다.
본 포스팅에서는
Redux Toolkit
을Next.js
에 적용하는 방식으로 설명
본격적인 사용에 앞서 현재 프로젝트 구조는 다음과 같다.
.
├── components
├── next.config.js
├── node_modules
├── package-lock.json
├── package.json
├── pages
├── public
├── reducers
│ └── title.js (리듀서 모듈)
│ └── desc.js (리듀서 모듈)
│ └── index.js (리듀서 모듈 통합)
├── store
│ └── configureStore.js (store 생성 && wrapper 생성)
아래 npm명령어를 통해 Redux Toolkit
관련 패키지를 설치한다.
npm i @reduxjs/toolkit
npm i react-redux
npm i next-redux-wrapper
npm i redux-logger --save-dev # (선택사항)
Redux-Toolkit
은 createSlice
를 사용하여 Reducer
모듈을 작성한다.
createSlice
는 createAction
, createReducer
함수가 내부적으로 사용된다.
createSlice
:action
과reducer
를 함께 정의
=>createSlice
=createAction
+createReducer
또한 선언된 슬라이스 name
을 따라 Reducer
와 그에 상응하는 액션 생성자 및 타입을 자동으로 생성한다.
정의를 마치면 Reducer
와 액션 생성함수는 다른 컴포넌트, 모듈에서 사용하기 위해 export
한다.
// reducers/title.js
import { createSlice } from '@reduxjs/toolkit';
const initialState = {
titleText: '',
};
const titleSlice = createSlice({
name: 'title',
initialState,
reducers: {
editTitle: (state, action) => { // title/editTitle
state.titleText = action.payload;
},
},
});
export const { editTitle } = titleSlice.actions;
export default titleSlice.reducer;
// reducers/desc.js
import { createSlice } from '@reduxjs/toolkit';
const initialState = {
descText: '',
};
const descSlice = createSlice({
name: 'desc',
initialState,
reducers: {
editDesc: (state, action) => { // desc/editDesc
state.descText = action.payload;
},
},
});
export const { editDesc } = descSlice.actions;
export default descSlice.reducer;
만약 액션의 내용을 Reducer
실행 이전에 편집하기를 원한다면 prepare
를 사용한다.
// reducers/title.js
import { createSlice } from '@reduxjs/toolkit';
const initialState = {
titleText: '',
};
const titleSlice = createSlice({
name: 'title',
initialState,
reducers: {
editTitle: (state, action) => {
state.titleText = action.payload;
},
// Reducer 실행 전 액션의 내용 편집
prepare: (text) => {
const name = '홍길동'
return {
payload: { name, text }
}
},
},
});
export const { editTitle } = titleSlice.actions;
export default titleSlice.reducer;
reducers/index.js
파일에서 combineReducers
를 사용하여 앞서 생성한 Reducer
모듈을 통합한다.
해당 과정에서 Server Side Rendering
작업 시 HYDRATE
라는 액션을 통해 서버의 Store
와 클라이언트의 Store
를 통합한다.
combineReducers
는 정의한 리듀서 모듈들을 결합
=> 추가되는 리듀서 모듈은combineReducers
함수의 인자로 전달
// reducers/index.js
import { combineReducers } from "@reduxjs/toolkit";
import { HYDRATE } from "next-redux-wrapper";
import title from './title';
import desc from './desc';
const reducer = (state, action) => {
if (action.type === HYDRATE) {
return {
...state,
...action.payload
};
}
return combineReducers({ // Reducer 모듈 통합
title,
desc,
})(state, action);
}
export default reducer;
configureStore
는 Redux
의 createStore
를 추상화한 스토어 구성 함수이다.
configureStore
에서는 Redux-Toolkit
에서 기본으로 제공하는 기능들을 추가로 정의할 수 있으며, 종류는 다음과 같다.
reducer
: 단일 함수를 전달하여 스토어의 루트 리듀서(root reducer)로 바로 사용middleware
: 리덕스 미들웨어를 담는 배열devTools
:Redux DevTools
활성화 여부preloadedState
: 스토어의 초기값 설정enchaners
: 미들웨어 보다 먼저 추가될store enhancer
store/configureStore.js
파일에서 configureStore
, createWrapper
함수를 사용하여 Store
, Wrapper
를 생성한다.
// store/configureStore.js
import { configureStore } from '@reduxjs/toolkit';
import { createWrapper } from "next-redux-wrapper";
import logger from 'redux-logger';
import reducer from '../reducers';
// Store 생성
const makeStore = (context) => configureStore({
reducer,
// logger 미들웨어 추가
middleware: (getDefaultMiddleware) => getDefaultMiddleware().concat(logger),
// 개발모드일 때 Redux DevTools 활성화
devTools: process.env.NODE_ENV !== 'production',
});
// Wrapper를 생성하여 생성된 Store를 바인딩
export const wrapper = createWrapper(makeStore, {
debug: process.env.NODE_ENV !== 'production',
});
위에서 생성한 Wrapper
의 withRedux
고차 컴포넌트(HOC, Higher Order Component)
로 _app.js
를 감싼다.
해당 과정을 통해 각 페이지에서 getStaticProps
, getServerSideProps
...등등 함수 내에서 스토어 접근을 가능하게 한다.
// pages/_app.js
import React from 'react';
import { ThemeProvider } from 'styled-components';
import "../styles/variables.less";
import GlobalStyles from '../styles/GlobalStyles';
import Theme from '../styles/Theme';
import { wrapper } from '../store/configureStore'; // wrapper 불러오기
const App = ({ Component, pageProps }) => {
return (
<>
<GlobalStyles />
<ThemeProvider theme={Theme}>
<Component {...pageProps} />
</ThemeProvider>
</>
);
};
export default wrapper.withRedux(App); // Wrapper의 withRedux HOC
위의 과정을 모두 마친 뒤 해당 컴포넌트에서 확인해보면 결과는 다음과 같다.
import React, { useCallback } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { Typography, Input } from 'antd';
import AppLayout from '../components/AppLayout';
import { editTitle } from '../reducers/title'; // 액션 생성함수 export
import { editDesc } from '../reducers/desc'; // 액션 생성함수 export
const Home = () => {
const dispatch = useDispatch();
const { titleText } = useSelector((state) => state.title); // state 불러오기
const { descText } = useSelector((state) => state.desc); // state 불러오기
const onChangeTitle = useCallback((e) => {
dispatch(editTitle(e)); // 액션 생성함수를 Dispatch
}, []);
const onChangeDesc = useCallback((e) => {
dispatch(editDesc(e)); // 액션 생성함수를 Dispatch
}, []);
return (
<AppLayout>
<Typography.Title>Title</Typography.Title>
<Typography.Text strong>{titleText}</Typography.Text>
<Input.Search enterButton="변경" type='text' onSearch={onChangeTitle} />
<Typography.Title>DESC</Typography.Title>
<Typography.Text strong>{descText}</Typography.Text>
<Input.Search enterButton="변경" type='text' onSearch={onChangeDesc} />
</AppLayout>
)
};
export default Home;
Redux-Toolkit
의 비동기 작업
Redux-Toolkit
은 내부적으로 redux-thunk
라이브러리를 사용하는 createAsyncThunk
를 통해 비동기 작업을 수행한다.
단, createAsyncThunk
는 createSlice
의 reducers
와 달리 자동으로 action creator
가 생성되지 않아 수동으로 생성해야 한다.
extraReducers
는 createSlice
가 생성한 액션 타입 외 다른 액션 타입에 응답할 수 있도록 한다.
즉, 슬라이스 리듀서에 맵핑된 내부 액션 타입이 아닌, 외부의 액션을 참조하는 용도로 사용한다.
import { createSlice } from '@reduxjs/toolkit';
import { loadSales } from '../actions/sales'; // action creator
const initialState = {
value: null,
loadSalesLoading: false,
loadSalesDone: false,
loadSalesError: null,
};
const salesSlice = createSlice({
name: 'sales',
initialState,
reducers: {},
extraReducers: (builder) => builder
.addCase(loadSales.pending, (state) => { // Loading
state.loadSalesLoading = true;
state.loadSalesDone = false;
state.loadSalesError = null;
})
.addCase(loadSales.fulfilled, (state, action) => { // Success
state.value = action.payload.value;
state.loadSalesLoading = false;
state.loadSalesDone = true;
})
.addCase(loadSales.rejected, (state, action) => { // Error
state.loadSalesLoading = false;
state.loadSalesError = action.error.message;
})
});
export default salesSlice.reducer;
또한 extraReducers
에 추가된 케이스 리듀서는 Promise
의 진행 상태에 따라 아래와 같은 3가지 상태로 리듀서를 실행할 수 있다.
pending
: 로딩
fulfilled
: 성공
rejected
: 실패
createAsyncThunk
는 액션 타입 문자열과 Promise
를 return
하는 콜백 함수를 인자로 받는다.
그리고 주어진 액션 타입을 접두어로 사용하여 Promise
생명 주기 기반의 액션 타입을 생성한다.
import axios from 'axios';
import { createAsyncThunk } from '@reduxjs/toolkit';
export const loadSales = createAsyncThunk('sales/loadSales', async (data, { rejectWithValue }) => {
try {
// response는 총 판매량을 조회하여 return
// {"value":총 판매량}
const response = await axios.get('https://api.countapi.xyz/hit/opesaljkdfslkjfsadf.com/visits');
return response.data;
} catch (error) {
// 오류가 발생하면 error를 return
return rejectWithValue(error.response.data);
}
});
위의 과정을 통해 작성된 비동기 작업은 필요한 시점에 컴포넌트에서 Dispatch
하여 사용한다.
import React, { useCallback } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { Typography, Input, Button } from 'antd';
import AppLayout from '../components/AppLayout';
import { editTitle } from '../reducers/title';
import { editDesc } from '../reducers/desc';
import { loadSales } from '../actions/sales'; // action 불러오기
const Home = () => {
const dispatch = useDispatch();
const { titleText } = useSelector((state) => state.title);
const { descText } = useSelector((state) => state.desc);
// 비동기 작업의 state
const { value, loadSalesLoading } = useSelector((state) => state.sales);
const onChangeTitle = useCallback((e) => {
dispatch(editTitle(e));
}, []);
const onChangeDesc = useCallback((e) => {
dispatch(editDesc(e));
}, []);
const onClickSales = useCallback(() => {
// 비동기 action dispatch
dispatch(loadSales());
}, []);
return (
<AppLayout>
<Typography.Title>Title</Typography.Title>
<Typography.Text strong>{titleText}</Typography.Text>
<Input.Search enterButton="변경" type='text' onSearch={onChangeTitle} />
<Typography.Title>DESC</Typography.Title>
<Typography.Text strong>{descText}</Typography.Text>
<Input.Search enterButton="변경" type='text' onSearch={onChangeDesc}/>
// 총 판매량 조회
<Typography.Title>TOTAL SALES</Typography.Title>
{
loadSalesLoading
? <Typography.Text strong>로딩 중...</Typography.Text>
: <Typography.Text strong>{value}</Typography.Text>
}
<Button type='primary' onClick={onClickSales}>조회</Button>
</AppLayout>
)
};
export default Home;
Redux toolkit - 생활코딩
Redux Toolkit | Redux Toolkit
Redux Toolkit Tutorial - Codevolution