리덕스는 자바스크립트에서 상태 관리를 편하게 할 수 있도록 도와주는 라이브러리입니다.
리덕스를 사용하기 위해서 우선 리덕스의 핵심이 되는 개념을 알고 있어야 합니다.
새로운 리액트 프로젝트를 생성해줍니다.
yarn create react-app myApp
해당 디렉터리에서 redux를 설치해줍니다.
yarn add redux
리덕스에서 애플리케이션의 상태가 관리되는 하나의 공간을 말합니다.
이 공간에서 애플리케이션이 필요로 하는 모든 상태를 관리하고 있습니다.
store
는 reducer
를 인자로 받아 생성됩니다.
리듀서는 기본 상태와 상태를 업데이트 하는 방법을 알고 있는 함수입니다.
상태를 업데이트 하기 위한 데이터를 담고있는 객체입니다.
리듀서는 action
객체에 담긴 type
을 통해 상태를 변화시킵니다.
이 객체는 type
을 필수로 가져야하고, 나머지 값들은 사용자가 자유롭게 담아서 보낼 수 있습니다.
하지만 추가적인 데이터는 payload
라는 속성에 담아 보내는것이 개발자들 간의 약속입니다.
참고 : 리덕스 공식 튜토리얼
action
이 리듀서에 전달되기 이전에 어떤 작업을 하고 싶을 때 middleware을 사용할 수 있습니다.
보통 비동기 처리를 위해 자주 사용되며 redux-thunk와 redux-saga와 같은 라이브러리를 통해 많이 사용됩니다.
주차장 차단기는 미리 설정된 주파수로 신호가 들어오면 그 신호에 따라 열리고 닫힙니다.
열리고 닫히는 방식은 주차장 차단기에 이미 프로그래밍 되어 있습니다.
우선 주차장 차단기에 리모컨을 등록합니다. 리모컨의 버튼을 누르는 것으로 주차장 차단기에 신호를 보낼 수 있습니다. 이를 통해 차단기를 열고 닫을 수 있습니다.
리모컨의 버튼을 누르면 주차장 차단기에 무선 신호를 전달해줍니다.
결국 우리는 리모컨의 열림, 닫힘 버튼을 눌러 차단기를 열고, 닫을 수 있습니다.
우리는 주차장 차단기와 리모컨을 미리 만들어두고, 상황에 맞는 버튼을 눌러 차단기를 열고 닫아야 하는것입니다.
이 문장을 리덕스로 바꿔 표현하면,
우리는 리듀서와 스토어를 미리 만들어두고, 상황에 맞는 액션을 보내 상태를 업데이트 할 수 있습니다.
리덕스를 이용한 상태 관리는 위에서 설명한 개념들을 합쳐서 이루어집니다.
우리는 리덕스로 상태를 관리하기 위해서 리듀서와 스토어를 미리 만들어두고,
상태를 변화시키기 위해 상황에 맞는 액션 객체를 스토어에 보내기만 하면 됩니다.
import { createStore } from 'redux';
// 액션의 타입을 미리 정의합니다.
const PLUS = 'PLUS';
const MINUS = 'MINUS';
// 액션 객체를 반환하는 함수를 미리 정의해 둡니다.
const plusNumber = () => ({ type: PLUS });
const minusNumber = () => ({ type: MINUS });
// 기본 상태와 상태를 업데이트 하는 방법을 알고 있는 리듀서를 정의합니다.
const counterReducer = (state = 0, action) => {
switch (action.type) {
case PLUS:
return state + 1;
case MINUS:
return state - 1;
default:
return state;
}
};
// 애플리케이션의 모든 상태를 관리하는 스토어에 리듀서를 전달합니다.
const store = createStore(counterReducer);
function Counter() {
const number = store.getState();
return (
<> // 스토어에게 액션 객체를 전달해 업데이트 요청을 합니다.
<button onClick={() => store.dispatch(minusNumber())}>minus</button>
{number}
<button onClick={() => store.dispatch(plusNumber())}>plus</button>
</>
);
}
function App() {
return <Counter />
}
그럼 이제 숫자를 증가시켜 봅시다!
왜 값이 바뀌지 않을까요?
값이 변화하는지 확인하기 위해 store를 subscribe(구독)해 action이 발생할 때마다 상태를 관찰하는 함수가 호출되도록 하겠습니다.
// 상태를 관찰하는 함수
const logger = () => {
const state = store.getState();
console.log(state);
};
// 스토어를 구독
store.subscribe(logger);
값은 잘 바뀌는데, 뷰는 바뀌지 않네요😅
store에 담겨있는 리덕스의 상태는 리액트의 상태로 취급되지 않기 때문입니다.
리덕스는 리액트를 위해 등장한 라이브러리가 아닙니다. 그래서 리덕스를 사용하는 것만으로 리액트는 값이 변화하는 것을 알 수 없습니다.
react-redux
react-redux로 리덕스의 상태를 리액트의 상태로 취급할 수 있습니다.
yarn add react-redux
Provider
react-redux는 스토어를 주입하는 Provider 컴포넌트를 제공합니다. 하위 컴포넌트에서 리덕스 스토어에 접근할 수 있도록 상태를 주입합니다.
Provider 컴포넌트의 인자로 값을 사용할 컴포넌트를 감싸줍니다. Context API의 Provider 컴포넌트 인자로 값을 전달하는 것과 동일한 형태로 store를 전달합니다.
import { Provider } from 'react-redux';
import store from 'store';
function App() {
return (
<Provider store={store}>
<Counter />
</Provider>
);
}
하위 컴포넌트에서는 이제 스토어에 접근할 수 있습니다.
useSelector
변화하는 상태를 화면에 반영하기 위해서 리덕스의 상태가 리액트의 상태로 취급될 수 있도록 이어주어야 합니다.
useSelector
는 리덕스의 상태를 리액트의 상태로 연결시켜주는 역할을 하는 함수입니다.
콜백함수에는 스토어의 상태가 전달됩니다. 콜백함수에서 사용할 값을 반환하면 스토어의 상태가 리액트의 상태로 취급됩니다.
import { useSelector } from 'react-redux';
import { minusNumber, plusNumber } from 'counterReducer';
import store from 'store';
function Counter() {
// useSelector 사용
const number = useSelector(state => state.number);
return (
<>
<button onClick={() => store.dispatch(minusNumber())}>minus</button>
{number}
<button onClick={() => store.dispatch(plusNumber())}>plus</button>
</>
);
}
리덕스의 상태를 업데이트하기 위해 store를 불러오고 있는데, 우리는 스토어를 주입 받았기에 위 과정을 생략하고 스토어에 바로 업데이트 할 수 있는 hook이 있습니다.
useDispatch
리덕스의 상태를 바로 업데이트 하기 위해서 useDispatch
함수를 사용할 수 있습니다.
import { useSelector, useDispatch } from 'react-redux';
function Counter() {
const number = useSelector(state => state.number);
const dispatch = useDispatch();
return (
<>
<button onClick={() => dispatch(minusNumber())}>minus</button>
{number}
<button onClick={() => dispatch(plusNumber())}>plus</button>
</>
);
}
이제 리액트는 리덕스의 상태 변화를 감지하고 이를 뷰에 반영할 수 있습니다.
지금까지 리덕스의 상태를 리액트의 상태로 취급하도록 코드를 작성했습니다.
여기 카운터, 게시글의 데이터를 저장하는 기능을 하는 리듀서가 있습니다.
const initialData = {
number: 0,
title: '',
text: '',
};
const PLUS = 'PLUS';
const MINUS = 'MINUS';
const UPLOAD_POST = 'UPLOAD_POST';
const mainReducer = (state = initialData, action) => {
switch (action.type) {
case PLUS:
return { ...state, number: state.number + 1 };
case MINUS:
return { ...state, number: state.number - 1 };
case UPLOAD_POST:
return { ...state, title: action.payload.title, text: action.payload.text };
default:
return state;
}
};
const plusNumber = () => ({ type: PLUS });
const minusNumber = () => ({ type: MINUS });
const uploadPost = (title, text) => ({
type: UPLOAD_POST,
payload: {
title,
text,
},
});
const store = createStore(mainReducer);
왜 상태를 직접 변경하지 않고 spread 문법으로 상태를 업데이트 하는걸까요?
return { ...state, number: state.number + 1 };
그냥 이렇게 변경하면 안되나요 ?
state.number += 1;
return state;
리덕스는 상태의 변경을 감지하기 위해 객체 간 얕은 비교를 수행하기 때문에 직접 상태를 변경하면 안됩니다.
그래서 우리는 새로운 상태를 반영한 객체를 생성하고 반환해 리덕스가 이전의 상태 객체와 비교할 수 있도록 해주는 것입니다.
immer.js를 사용해 객체의 프로퍼티를 직접 변경할 수도 있지만, 여기서는 다루지 않습니다.
다시 본론으로 돌아와, 위 코드처럼 하나의 리듀서에서 애플리케이션의 모든 상태를 관리하게 되면 어떨까요?
애플리케이션의 크기가 커지면 커질수록 관리하기 어렵게 될겁니다.
이런 문제를 해결하기 위해 리듀서를 나눠서 관리할 수 있습니다.
그렇게 되면 이 리듀서가 어떠한 상태를 가지고 관리하는지 한눈에 알아볼 수 있게 되겠죠.
이제 리듀서를 나누어 작성해 봅시다.
Ducks Pattern
이미 리덕스의 복잡한 보일러플레이트를 경험한 개발자들은 reducer를 잘 관리할 수 있는 디자인 패턴을 정의해두었습니다.
널리 쓰이는 패턴으로 Ducks 패턴이 있습니다.
덕스 패턴에서는 액션 타입, 액션 생성 함수, 리듀서를 한 파일에서 관리합니다.
덕스 패턴을 사용할 때는 반드시 따라야 하는 규칙이 있습니다.
MUST export default a function called reducer()
reducer 함수는 반드시 export default 를 통해 내보내야 합니다.
MUST export its action creators as functions
액션을 생성하는 함수는 반드시 export 해야 합니다.
MUST have action types in the form npm-module-or-app/reducer/ACTION_TYPE
반드시 액션 타입의 값을npm-module-or-app/reducer/ACTION_TYPE
형식을 따라 작성해야 합니다.
만일 NPM module을 만드는 게 아니라면reducer/ACTION_TYPE
같은 형식으로 만들어도 됩니다.
액션의 이름이 중복되지 않도록 접두사를 달아서 작성하세요.
MAY export its action types as UPPER_SNAKE_CASE, if an external reducer needs to listen for them, or if it is a published reusable library
필수는 아니지만 외부 리듀서가 모듈 내 액션 타입을 바라보거나 모듈이 재사용이 가능한 라이브러리인 경우 해당 액션 타입을 대문자 SNAKE_CASE 형태로 export 하세요.
덕스 패턴을 활용해 리듀서를 나누어 볼까요?
// numberReducer.js
const counterData = {
number: 0,
};
export const PLUS = 'counter/PLUS';
export const MINUS = 'counter/MINUS';
export default const counterReducer = (state = numberData, action) => {
switch (action.type) {
case PLUS:
return { ...state, number: state.number + 1 };
case MINUS:
return { ...state, number: state.number - 1 };
default:
return state;
}
}
export const plusNumber = () => ({ type: PLUS });
export const minusNumber = () => ({ type: MINUS });
// postReducer.js
const postData = {
title: '',
text: '',
};
const UPLOAD = 'post/UPLOAD';
export default const postReducer = (state = postData, action) => {
switch (action.type) {
case UPLOAD:
return { ...state, title: action.payload.title, text: action.payload.text };
default:
return state;
}
}
export const upload = (title, text) => ({
type: UPLOAD,
payload: {
title,
text,
},
});
이제 각 리듀서가 어떤 역할을 하는지 쉽게 알아볼 수 있습니다.
리덕스는 여러 개의 리듀서를 하나의 리듀서로 합칠 수 있는 combineReducers
함수를 제공합니다. 흩어져 있는 여러 개의 리듀서를 하나로 모아 관리할 수 있습니다.
// rootReducer.js
import { combineReducers } from 'redux';
import postReducer from 'postReducer.js';
import counterReducer from 'counterReducer.js';
const rootReducer = combineReducers({ counterReducer, postReducer });
export default rootReducer;
리듀서들을 하나로 모아 스토어에게 전달합니다.
// store.js
import { createStore } from 'redux';
import rootReducer from 'rootReducer';
const store = createStore(rootReducer);
export default store;
이제 useSelector의 콜백함수의 인자에는 하나로 모은 리듀서가 전달됩니다.
const { number } = useSelector(state => state.counterReducer);
상태를 꺼내서 사용하고 싶다면 이렇게 하면 됩니다.
redux-toolkit
지금까지 우리는 기본적인 리덕스를 사용하는 방법에 대해 살펴보았는데요, 더 편하게 리덕스를 사용할 수 있는 redux-toolkit
이 있습니다. 공식문서에서도 사용을 강력히 권고합니다.
기존의 리덕스는 사용하기 위한 보일러 플레이트가 너무 많았습니다.
이러한 문제와 보일러 플레이트를 줄이고 편하게 사용하기 위해 redux-toolkit
이 등장합니다.
이제 리덕스 툴킷을 사용해 더 쉽게 리덕스를 사용해봅시다.
액션 생성 함수와 리듀서를 한 번에 작성할 수 있도록 도와주는 함수입니다.
하나의 slice는 작은 스토어의 역할을 하게 됩니다.
createSlice에 리듀서를 작성하면, 알아서 액션 객체를 반환하는 함수가 리듀서의 키 값을 가지고 자동으로 생성됩니다.
이전에 우리는 리덕스를 사용하기 위해 action type을 정의하고, 액션 객체를 생성하는 함수를 작성했습니다.
또, 리듀서에는 action type이 전달됐을 때 어떻게 상태를 업데이트 할 것인지 나누어 작성해야 했습니다.
하나의 작업을 위해 미리 준비해야 할 것이 너무나도 많았습니다. 하지만 createSlice
함수를 사용하면 이 복잡한 과정을 모두 생략할 수 있습니다.
이전에 리듀서에서 작성했던 것처럼 상태의 불변성을 유지하면서 새로운 객체를 만들어 반환할 필요도 없습니다. 직접 상태에 접근해 변경할 수 있습니다.
액션을 정의하는 객체를 만들기 위해 직접 만들 필요도 없습니다. createSlice
에 세 가지 값을 작성하기만 하면 됩니다.
createSlice 함수는 다음과 같은 인자를 전달받습니다.
name
: 슬라이스의 이름
initialState
: 기본 값
reducers
: 상태를 업데이트 하는 함수
const counterSlice = createSlice({
name: 'counter',
initialState: 0,
reducers: {
plus: (state, action) => state + 1,
minus: (state, action) => state - 1,
},
});
// actions는 액션 생성 함수를 가지고 있는 프로퍼티 입니다.
export const { plus, minus } = counterSlice.actions;
createSlice 함수의 반환 객체가 가지고 있는 actions
에서 액션 객체를 만드는 함수를 꺼내 사용할 수 있습니다.
리덕스 툴킷의
createAction
을 사용해 액션 객체를 생성할 수도 있습니다.
하지만 createSlice
만 작성해도 자동으로 액션타입을 만들어주기 때문에 이 기능을 사용하는 편입니다.
액션 객체에 추가적인 데이터를 담아 전달하고 싶다면, 액션 객체를 반환하는 함수의 인자로 값을 전달할 수 있습니다.
전달되는 인자는 액션 객체의 payload의 값으로 받을 수 있습니다.
const dispatch = useDispatch();
dispatch(plus({ value: 5 }));
// payload: { value : 5 }
얼마나 코드가 줄어들었는지 기존에 작성했던 코드와 한번 비교해 보겠습니다.
const counterData = {
number: 0,
};
export const PLUS = 'counter/PLUS';
export const MINUS = 'counter/MINUS';
export default const counterReducer = (state = numberData, action) => {
switch (action.type) {
case PLUS:
return { ...state, number: state.number + 1 };
case MINUS:
return { ...state, number: state.number - 1 };
default:
return state;
}
}
리덕스 툴킷은 immer.js
가 내장되어 있으므로 사용자가 데이터를 불변하게 다루지 않아도 됩니다.
const counterSlice = createSlice({
name: 'counter',
initialState: 0,
reducers: {
plus: (state, action) => state + 1,
minus: (state, action) => state - 1,
},
});
😳
immer.js
불변성을 쉽게 관리할 수 있게 도와주는 자바스크립트 라이브러리
상태를 직접 수정하는 것처럼 코드를 작성해도immer.js
가 불변성을 유지하도록 도와준다.
const PLUS = 'counter/PLUS';
const MINUS = 'counter/MINUS';
export const plusNumber = () => ({ type: PLUS });
export const minusNumber = () => ({ type: MINUS });
plusNumber(); // { type: counter/PLUS }
export const { plus, minus } = counterSlice.actions;
plus(); // { type: counter/plus }
configureStore
우리는 기존에 여러 개의 리듀서를 combineReducers
함수를 사용해 하나로 모았습니다.
const rootReducer = combineReducers({ counterReducer, postReducer });
const store = createStore(rootReducer);
리덕스 툴킷은 여러 개의 리듀서를 한번에 스토어로 전달할 수 있는 configureStore 함수를 제공합니다.
const store = configureStore({
reducer: { number: counterSlice.reducer },
});
Ducks pattern
리덕스 툴킷도 덕스 패턴을 적용할 수 있습니다.
리듀서 함수는 반드시 export default를 통해 내보내야 합니다.
액션을 생성하는 함수는 반드시 export 해야합니다.
리덕스 툴킷에 덕스 패턴을 적용해 보겠습니다.
const counterSlice = createSlice({
name: 'counter',
initialState: 0,
reducers: {
plus: (state, action) => state + 1,
minus: (state, action) => state - 1,
},
});
// 액션을 생성하는 함수를 export 합니다.
export const { plus, minus } = counterSlice.actions;
// 리듀서 함수를 내보냅니다.
export default counterSlice.reducer;
이렇게 지금까지 리덕스에서의 상태 관리 방법에 대해서 알아보았습니다.
설명에 오류가 있다면 댓글로 알려주시면 감사하겠습니다.