최근 동아리에서 스터디를 진행하게 되었다.
그 첫 번째 주제로 리덕스에 대해 공부하기로 하였다.
지금부터 리덕스에 대해 알아보자.
리덕스에 대해 알아보기 전에 상태관리 라이브러에 대해 알아보았다.
아래를 통해 상태관리 라이브러리에 대해 알아보자.
JS의 상태관리 라이브러리
Recoil이 React만을 위한 상태관리 라이브러리라면,
Redux는 vanila js 에서도 사용이 가능하다.
리덕스는 단방향으로 데이터가 흐른다.
즉 상태와 버그 예측이 쉽다.
고로 디버깅
하기 좋다.
액션과 상태 변화가 모두 로그에 남는다
위 주된 두가지 특징이 유지보수와 디버깅을 용이하게 해준다.
Recoil이 더 사용하기 쉽고 단순하지만 React에만 국한되어 있다면,
리덕스는 React뿐만 아니라, 바닐라, Ts, Vue 등 여러 환경에서 사용할 수 있다.
리덕스는 여러가지 유용한 미들웨어를 제공한다.
비동기 처리 작업 및 추가적인 작업에 있어서 용이하다.
사전에 정의한 동작만 처리한다.
즉, 상태 변화를 예측, 통제할 수 있다.
(의도하지 않은 상태 변화를 방지한다.)
리덕스의 구성과 동작 방식은 생각보다 단순하다.
정확한 정의를 찾기도 힘들 뿐더러 그또한 추상적이어서 나만의 해석으로 정의하자면,
이름처럼 어떠한 상태의 업데이트를 위해 어떤 액션을 취해주어야 하는지 명시해주는 객체.
즉, 실직적으로 상태를 변경시키는 리듀서가 어떤 동작을 취해야 하는지 참조하는 객체라고 할 수 있다.
액션의 구성 요소를 크게 두가지로 분류할 수 있는데
{
type: 'ADD_TODO',
text: '밥먹기'
}
{
type: 'INCREASE',
num
}
매번 액션 객체를 직접 장성하기 번거로울 뿐만 아니라, 만드는 과정에서 실수를 할 수 있기에 이를 함수로 만들어 관리하는 것이 좋다.
아래와 같은 구조로 액션 객체를 생성하는 함수를 만들 수 있다.
const addTodo = text => ({
type: 'ADD_TODO',
text
});
변화를 일으키는 함수.
즉, 실질적인 로직을 가지고 state와 액션 객체를 참조하여 상태를 업데이트 시킨다.
현재 상태(state)와 액션 객체를 파라미터로 받아 그 값들을 참조한다.
const initialState = { // 기본값 설정을 위한 객체
counter: 1
};
const reducer = (state = initialState, action) => {
switch (action.type) {
case INCREMENT:
return {
counter: state.counter + 1
};
default:
return state; // 지정되지 않은 요청시 값 반환
}
}
프로젝트에 리덕스를 적용하기 위해 스토어를 만들어야한다.
하나의 프로젝트는 하나의 스토어만 가질 수 있으며, 스토어는 현재 애플리케이션 상태와 리듀서, 그외 내장함수들을 포함하고 있다.
const store = createStore(reducer);
console.log(store.getState()); // getState() 함수를 통해 현재 스토어에 들어있는 상태를 조회한다.
위와 같이 createStore함수를 통해 스토어를 생성할 수 있으며, 파라미터로 리듀서를 전달한다.
디스패치는 액션을 발생시키는 역할을 한다.
트리거 같은 존재이다.
const button = element.addEventListener("click", increaes);
const increase = () => {
store.dispatch(increase(1));
};
const decrease = () => {
store.dispatch(decrease());
};
위와 같이 dispatch()함수에 액션 객체를 파라미터로 전달하는 방식으로 사용할 수 있다.
스토어의 내장함수로 아래와 같이 subscribe()함수에 리스너 함수를 파라미터로 넣어 호출하여주면, 스토어가 업데이트될 때마다 이 리스너 함수를 호출시켜줍니다.
const listener = () => {
console.log("상태가 업데이트됨");
}
const unsubscribe = store.subscribe(listener);
unsubscribe(); // 추후 구독을 비활성화할 때 함수를 호출
바닐라 Js 에서는 subscribe 함수를 직접 사용하지만,
React 환경에서는 react-redux라는 라이브러리가 대신 처리해줄 수 있다.
하나의 프로젝트 안에는 하나의 스토어만 존재한다.
스토어를 복수 사용하는 것이 아예 불가능한 것은 아니지만 권장하지 않는다.
리덕스는 읽기 전용 상태이다.
상태를 업데이트할 때 기존의 객체는 건드리지 않고 새로운 객체를 생성, 반환해주어 불변성을 유지해야 한다.
리듀서 함수는 순수한 함수여야 한다.
순수한 함수는 다음 조건을 충족하여야 한다.
위 조건들을 정리해보면,
리듀서는 파라미터 이외의 값에 의존하지 않아야 하며, 멱등성과 불변성을 지켜야한다.
React 환경에서 리덕스를 사용해 카운터를 만들어보자!
리액트 프로젝트에서 리덕스를 사용하려면 다음 라이브러리들이 필요하다.
const INCREASE = 'counter/INCREASE';
const DECREASE = 'counter/DECREASE';
매직 리터럴은 사용하지 않기 위해 변수로 선언하였으며,
중복을 방지하기 위해 모듈이름/액션이름
의 형태로 작성하였다.
export const increase = () => ({ type: INCREASE});
export const decrease = () => ({ type: DECREASE});
생성 함수를 export 하여 추후에 다른 파일에서 사용할 수 있도록 하였다.
const initialState = {
number: 0
};
const counter = (state = initialState, action) => {
switch (action.type) {
case INCREASE:
return {
number: state.number + 1
};
case DECREASE:
return {
number: state.number - 1
};
default:
return state; // 의도되지 않은 요청은 그대로 반환시켜준다.
}
}
스토어 생성 방식은 동일하다.
하지만 구조상 적용 방식은 차이가 있다.
아래 코드를 참고하자
// index.js
...
import { createStore } from 'redux';
import { Provider } from 'react-redux';
const store = createStore(reducer);
root.render(
<Provider store={store}>
<App />
</Provider>
);
react-redux에서 제공하는 Provider 컴포넌트에 스토어를 파라미터로 전달하고, App 컴포넌트를 감싸는 방식으로 프로젝트 전반에 리덕스 스토어를 적용시켜줄 수 있다.
프로젝트에 따라 리듀서는 늘어날 수 있다.
이러한 여러개의 리듀서는 하나로 합쳐서 사용해야 한다.
그 이유는 스토어를 생성할 때 파라미터로 리듀서를 주어야 하는데, 이때 하나의 리듀서만을 사용해야 한다.
고로 기존에 만들었던 리듀서들을 하나로 합쳐야 하는데, 이때 combineReducers라는 리덕스 유틸 함수를 사용한다.
방법을 아래와 같다.
// modules/index.js
import { combineReducers } from 'redux';
import counter from './counter';
import todos from './todos'; // 투두 리듀서가 있다고 가정한다.
const rootReducer = combineReducer({
counter,
todos,
})
export default rootReducer;
// index.js
...
import { createStore } from 'redux';
import { Provider } from 'react-redux';
import rootReducer from './modules'
const store = createStore(rootReducer);
root.render(
<Provider store={store}>
<App />
</Provider>
);
import Counter from '아무튼 카운터 컴포넌트 주소'; // 카운터 ui 컴포넌트
const CounterContainer = () => {
return <Counter />;
};
export default CounterContainer;
위 컴포넌트를 리덕스와 연동하려면 react-redux에서 제공하는 connect 함수를 사용해야 한다.
connect 함수에는 리덕스 스토어 안에 있는 상태를 넘겨주는 mapStateToProps와
액션 생성 함수를 넘겨주는 mapDispatchToProps라는 함수를 사용한다.
위 두 함수는 connect 함수 안에서 선언하지 않고 익명 함수로 사용할 수 있다.
바로 적용하면
const CounterContainer = ({ number, increase, decrease }) => {
return (
<Counter number={number} onIncrease={increase} onDecrease={decrease} /> // 액션 생성 함수 전달
);
};
export default connect(
state => ({
number: state.counter.number,
}),
dispatch => ({
increase: () => dispatch(increase()),
decrease: () => dispatch(decrease()),
}),
)(CounterContainer);
redux에서 제공하는 bindActionCreators 유틸 함수를 사용하면 이마저도 줄일 수 있다.
하지만 여기서 또!
이것 조차도 생략할 수 있다.
그냥 increase, decrease를 두 번째 파라미터에 객체 형태로 넣어 주면 이마저도 생략이 가능하다.
(connect함수가 내부적으로 bindActionCreators의 작업도 처리해준다.)
const CounterContainer = ({ number, increase, decrease }) => {
return (
<Counter number={number} onIncrease={increase} onDecrease={decrease} /> // 액션 생성 함수 전달
);
};
export default connect(
state => ({
number: state.counter.number,
}),
{
increase,
decrease,
},
)(CounterContainer);
이 얼마나 깔끔한가
redux-actions 라이브러리는 액션과 관련된 작업을 더 간결하게 만들어준다.
createAction함수를 이용하여 액션 생성 함수를 더 간단하게 액션 생성 함수를 선언할 수 있다.
import { createAction } from 'redux-actions';
const INCREASE = 'counter/INCREASE';
const DECREASE = 'counter/DECREASE';
export const increase = createAction(INCREASE);
export const decrease = createAction(DECREASE);
매번 직접 객체를 선언하지 않아도 된다.
redux-actions의 handleActions라는 함수를 통해 switch문과 같은 조건문을 더 직관적이게 만들 수 있따.
import { createAction, handleActions } from 'redux-actions';
... // 대충 액션 생성 함수, 기본값
const counter = handleActions(
{
[INCREASE]: (state, action) => ({ number: state.number + 1}),
[DECREASE]: (state, action) => ({ number: state.number - 1}),
},
initialState,
);
export default counter;
코드가 상당히 짧아지고 가독성 또한 좋아졌다(직관적이다).
위 코드에서는 파라미터가 필요하지 않았다.
하지만 파라미터가 필요하다면?
playload라는 이름으로 파라미터를 관리해주어야 한다.
const INSERT = 'todos/INSERT';
export const insert = createAction(INSERT);
const action = insert('밥먹기');
/*
결과: { type: INSERT, playload: '밥먹기'}
*/
값에 변형을 주고 싶다면 아래와 같이 선언해주면 된다.
const INSERT = 'todos/INSERT';
export const insert = createAction(INSERT, text => `${text}!!!!!!`);
const action = insert('밥먹기');
/*
결과: { type: INSERT, playload: '밥먹기!!!!!!'}
*/
const todos = handleActions(
{
[INSERT]: (state, { playload: todo }) => ({
...state,
todos: state.todos.concat(todo),
}),
},
initialState,
);
...
useSelector 훅을 사용하면 connect 함수를 사용하지 않고도 리덕스의 상태를 조회할 수 있다.
...
import { useSelector } from 'react-redux';
const CounterContainer = () => {
const number = useSelector(state => state.counter.number);
return <Counter number={number} />;
};
...
connect를 따로 선언하지 않으니 훨 간결하다.
useDispatch 훅은 컴포넌트 내부에서 dispatch를 사용할 수 있게 해준다.
...
import { useSelector, useDispatch } from 'react-redux';
const CounterContainer = () => {
const number = useSelector(state => state.counter.number);
const dispatch = useDispatch();
const onIncrease = useCallback(() => dispatch(increase()), [dispatch]);
const onDecrease = useCallback(() => dispatch(decrease()), [dispatch]);
return <Counter number={number} onIncrease={onIncrease} onDecrease={onDecrease} />;
};
...
useDispatch를 사용할 때는 이렇게 useCallback과 함께 사용하는 습관을 들여야 한다.
컴포넌트 성능 최적화 해야하니까...
useStore 훅을 사용하면 컴포넌트 내부에서 리덕스 스토어 객체를 직접 사용할 수 있다.
const store = useStore();
store.dispatch({ type: 'SAMPLE_ACTION' });
store.getState();
이건 알아만 두자.
쓸일 거의 없을거다.
useActions는 정식으로 릴리즈 되진 않았지만 사용은 가능하다.
리덕스 공식 웹에 코드가 있으니 참고하자!
react-redux공식 웹
이 훅은 여러개의 액션을 사용해야 하는 경우 코드를 훨씬 깔끔하게 정리할 수 있게 해준다.
lib 디렉토리에서 사용해보자!
코드는 아래와 같다.
import { bindActionCreators } from 'redux';
import { useDispatch } from 'react-redux';
import { useMemo } from 'react';
const useActions = (actions, deps) => {
const dispatch = useDispatch();
return useMemo(
() => {
if(Arrays.isArray(actions)){
return actions.map(a => bindActionCreators(a, dispatch))
}
return bindActionCreators(actions, dispatch);
},
deps ? [dispatch, ...deps] : deps
);
}
export default useActions;
위 코드를 아래와 같은 방식들로 사용할수 있다.
import useActions from 'lib/경로'; // lib 아니라도 상관없다.
const boundAC = useActions(actionCreator : Function, deps : any[])
const boundACsObject = useActions(actionCreators : Object<string, Function>, deps : any[])
const boundACsArray = useActions(actionCreators : Function[], deps : any[])
deps는 생략하여도 상관 없으나 최적화를 생각한다면 빈 배열이라도 넣는게 좋다.
예를 들면
import useActions from 'lib/경로';
const [onChangeINPUT, onInesrt, onToggle, onRemove] =
useActions([changeInput, insert, toggle, remove], []);
WOW
간결하다.
리덕스..
많은 글에서 리덕스는 찍먹조차 힘들다고 하였다.
그런데 막상 공부해보니 또 못할건 아니었다.
사람들이 리덕스를 어려워하는 이유는 크게 두가지 정도로 느껴진다.
이번에 리덕스를 공부하면서 어느정도 알게 되었긴 하지만
역시나 완전히 내것으로 만든 느낌은 아니다.
입에 머금고만 있는 느낌이다.
미들 웨어 더 공부하면서 이제는 몇번 씹어봐야겠다.
실제로 더 사용하면 언젠가 삼킬 수 있지 않을까 싶다.