리덕스
리덕스에서 사용되는 키워드 숙지하기
리덕스를 사용하게 될 때 앞으로 접하게 될 키워드들을 미리 알아봅시다. 이 키워드들 중에서 대부분은 이전에 useReducer를 사용해볼때 접해본 개념이기도 합니다.
액션 (Action)
상태에 어떠한 변화가 필요하게 될 땐, 우리는 액션이란 것을 발생시킵니다.
{
type: "TOGGLE_VALUE"
}
{
type: "ADD_TODO",
data: {
id: 0,
text: "리덕스 배우기"
}
}
{
type: "CHANGE_INPUT",
text: "안녕하세요"
}
액션 생성함수 (Action Creator)
액션 생성함수는, 액션을 만드는 함수입니다.
export function addTodo(data) {
return {
type: "ADD_TODO",
data
};
}
// 화살표 함수로도 만들 수 있습니다.
export const changeInput = text => ({
type: "CHANGE_INPUT",
text
});
이러한 액션 생성함수를 만들어서 사용하는 이유는 나중에 컴포넌트에서 더욱 쉽게 액션을 발생시키기 위함입니다. 그래서 보통 함수 앞에 export 키워드를 붙여서 다른 파일에서 불러와서 사용합니다.
리듀서 (Reducer)
리듀서는 변화를 일으키는 함수입니다..
function reducer(state, action) {
// 상태 업데이트 로직
return alteredState;
}
리듀서는, 현재의 상태와, 전달 받은 액션을 참고하여 새로운 상태를 만들어서 반환합니다. 이 리듀서는 useReducer 를 사용할때 작성하는 리듀서와 똑같은 형태를 가지고 있습니다.
만약 카운터를 위한 리듀서를 작성한다면 다음과 같이 작성할 수 있습니다.
function counter(state, action) {
switch (action.type) {
case 'INCREASE':
return state + 1;
case 'DECREASE':
return state - 1;
default:
return state;
}
}
useReducer 에선 일반적으로 default: 부분에 throw new Error('Unhandled Action')과 같이 에러를 발생시키도록 처리하는게 일반적인 반면 리덕스의 리듀서에서는 기존 state를 그대로 반환하도록 작성해야합니다.
리덕스를 사용 할 때에는 여러개의 리듀서를 만들고 이를 합쳐서 루트 리듀서 (Root Reducer)를 만들 수 있습니다. (루트 리듀서 안의 작은 리듀서들은 서브 리듀서라고 부릅니다.
스토어 (Store)
리덕스에서는 한 애플리케이션당 하나의 스토어를 만들게 됩니다. 스토어 안에는, 현재의 앱 상태와, 리듀서가 들어가있고, 추가적으로 몇가지 내장 함수들이 있습니다.
디스패치 (dispatch)
디스패치는 스토어의 내장함수 중 하나입니다. 디스패치는 액션을 발생 시키는 것 이라고 이해하시면 됩니다. dispatch 라는 함수에는 액션을 파라미터로 전달합니다.. dispatch(action) 이런식으로 말이죠.
그렇게 호출을 하면, 스토어는 리듀서 함수를 실행시켜서 해당 액션을 처리하는 로직이 있다면 액션을 참고하여 새로운 상태를 만들어줍니다.
구독 (subscribe)
구독 또한 스토어의 내장함수 중 하나입니다. subscribe 함수는, 함수 형태의 값을 파라미터로 받아옵니다. subscribe 함수에 특정 함수를 전달해주면, 액션이 디스패치 되었을 때 마다 전달해준 함수가 호출됩니다.
리액트에서 리덕스를 사용하게 될 때 보통 이 함수를 직접 사용하는 일은 별로 없습니다. 그 대신에 react-redux 라는 라이브러리에서 제공하는 connect 함수 또는 useSelector Hook 을 사용하여 리덕스 스토어의 상태에 구독합니다.
리덕스의 3가지 규칙
1. 하나의 애플리케이션 안에는 하나의 스토어가 있습니다.
하나의 애플리케이션에선 단 한개의 스토어를 만들어서 사용합니다. 여러개의 스토어를 사용하는것은 사실 가능하기는 하나, 권장되지는 않습니다. 특정 업데이트가 너무 빈번하게 일어나거나, 애플리케이션의 특정 부분을 완전히 분리시키게 될 때 여러개의 스토어를 만들 수도 있습니다. 하지만 그렇게 하면, 개발 도구를 활용하지 못하게 됩니다.
2. 상태는 읽기전용 입니다.
리액트에서 state 를 업데이트 해야 할 때, setState 를 사용하고, 배열을 업데이트 해야 할 때는 배열 자체에 push 를 직접 하지 않고, concat 같은 함수를 사용하여 기존의 배열은 수정하지 않고 새로운 배열을 만들어서 교체하는 방식으로 업데이트를 합니다. 엄청 깊은 구조로 되어있는 객체를 업데이트를 할 때도 마찬가지로, 기존의 객체는 건드리지 않고 Object.assign 을 사용하거나 spread 연산자 (...) 를 사용하여 업데이트 하곤 하죠.
리덕스에서도 마찬가지입니다. 기존의 상태는 건들이지 않고 새로운 상태를 생성하여 업데이트 해주는 방식으로 해주면, 나중에 개발자 도구를 통해서 뒤로 돌릴 수도 있고 다시 앞으로 돌릴 수도 있습니다.
리덕스에서 불변성을 유지해야 하는 이유는 내부적으로 데이터가 변경 되는 것을 감지하기 위하여 shallow equality 검사를 하기 때문입니다. 이를 통하여 객체의 변화를 감지 할 때 객체의 깊숙한 안쪽까지 비교를 하는 것이 아니라 겉핥기 식으로 비교를 하여 좋은 성능을 유지할 수 있는 것이죠.
우리는 이 튜토리얼에서는 Immutable.js 혹은 Immer.js 를 사용하여 불변성을 유지하며 상태를 관리하는 방법에 대해서 다뤄보게 됩니다. 불변성과 Immutable.js 가 익숙하지 않다면 리액트의 불변함, 그리고 컴포넌트에서 Immutable.js 사용하기 포스트를 읽으시면 도움이 될거에요.
3. 변화를 일으키는 함수, 리듀서는 순수한 함수여야 합니다.
순수한 함수, 라는 개념이 익숙하지 않으시죠. 다음 사항을 기억해주세요.
- 리듀서 함수는 이전 상태와, 액션 객체를 파라미터로 받습니다.
- 이전의 상태는 절대로 건들이지 않고, 변화를 일으킨 새로운 상태 객체를 만들어서 반환합니다.
- 똑같은 파라미터로 호출된 리듀서 함수는 언제나 똑같은 결과값을 반환해야만 합니다.
3가지 사항을 주의해주세요. 동일한 인풋이라면 언제나 동일한 아웃풋이 있어야 합니다. 그런데 일부 로직들 중에서는 실행 할 때마다 다른 결과값이 나타날 수도 있죠. new Date() 를 사용한다던지.. 랜덤 숫자를 생성한다던지.. 혹은, 네트워크에 요청을 한다던지! 그러한 작업은 결코 순수하지 않은 작업이므로, 리듀서 함수의 바깥에서 처리해줘야 합니다. 그런것을 하기 위해서, 리덕스 미들웨어 를 사용하곤 하죠.
리덕스 사용 할 준비하기
리덕스에서 리액트를 본격적으로 사용해보기 전에, 리액트 컴포넌트 없이, 리덕스에서 제공되는 기능들을 먼저 연습해보겠습니다.
먼저 새로운 프로젝트를 만들어주세요.
exercise.js
console.log('Hello!');
index.js
import React from 'react';
import ReactDOM from 'react-dom';
import './index.css';
import App from './App';
import * as serviceWorker from './serviceWorker';
import './exercise'
ReactDOM.render(<App />, document.getElementById('root'));
// If you want your app to work offline and load faster, you can change
// unregister() to register() below. Note this comes with some pitfalls.
// Learn more about service workers: https://bit.ly/CRA-PWA
serviceWorker.unregister();
그리고 나서 yarn start 명령어를 실행하여 개발서버를 실행한뒤 브라우저에서 개발자 도구를 열어보세요.
exercise.js
import { createStore } from 'redux';
// createStore는 스토어를 만들어주는 함수입니다.
// 리액트 프로젝트에서는 단 하나의 스토어를 만듭니다.
/* 리덕스에서 관리 할 상태 정의 */
const initialState = {
counter: 0,
text: '',
list: []
};
/* 액션 타입 정의 */
// 액션 타입은 주로 대문자로 작성합니다.
const INCREASE = 'INCREASE';
const DECREASE = 'DECREASE';
const CHANGE_TEXT = 'CHANGE_TEXT';
const ADD_TO_LIST = 'ADD_TO_LIST';
/* 액션 생성함수 정의 */
// 액션 생성함수는 주로 camelCase 로 작성합니다.
function increase() {
return {
type: INCREASE // 액션 객체에는 type 값이 필수입니다.
};
}
// 화살표 함수로 작성하는 것이 더욱 코드가 간단하기에,
// 이렇게 쓰는 것을 추천합니다.
const decrease = () => ({
type: DECREASE
});
const changeText = text => ({
type: CHANGE_TEXT,
text // 액션안에는 type 외에 추가적인 필드를 마음대로 넣을 수 있습니다.
});
const addToList = item => ({
type: ADD_TO_LIST,
item
});
/* 리듀서 만들기 */
// 위 액션 생성함수들을 통해 만들어진 객체들을 참조하여
// 새로운 상태를 만드는 함수를 만들어봅시다.
// 주의: 리듀서에서는 불변성을 꼭 지켜줘야 합니다!
function reducer(state = initialState, action) {
// state 의 초깃값을 initialState 로 지정했습니다.
switch (action.type) {
case INCREASE:
return {
...state,
counter: state.counter + 1
};
case DECREASE:
return {
...state,
counter: state.counter - 1
};
case CHANGE_TEXT:
return {
...state,
text: action.text
};
case ADD_TO_LIST:
return {
...state,
list: state.list.concat(action.item)
};
default:
return state;
}
}
/* 스토어 만들기 */
const store = createStore(reducer);
console.log(store.getState()); // 현재 store 안에 들어있는 상태를 조회합니다.
// 스토어안에 들어있는 상태가 바뀔 때 마다 호출되는 listener 함수
const listener = () => {
const state = store.getState();
console.log(state);
};
const unsubscribe = store.subscribe(listener);
// 구독을 해제하고 싶을 때는 unsubscribe() 를 호출하면 됩니다.
// 액션들을 디스패치 해봅시다.
store.dispatch(increase());
store.dispatch(decrease());
store.dispatch(changeText('안녕하세요'));
store.dispatch(addToList({ id: 1, text: '와우' }));
리덕스 스토어 안의 상태는 액션이 디스패치됨에 따라 업데이트됩니다. 위 코드에서는 우리가 listener라는 함수를 만들어서 리덕스 상태에 변화가 생겼을 때 마다 콘솔에 상태를 출력하도록 처리하였습니다.
코드의 최하단에서는 여러가지 액션들을 디스패치 했는데요, 액션이 디스패치 될 때마다 상태가 바뀌고, 이에 따라 listener 함수가 호출 될 것입니다.
결과가 잘 나타난 것을 확인하셨다면 이제 본격적으로 리액트 프로젝트에 리덕스를 적용해보도록 하겠습니다. index.js 에서 방금 만든 exercise.js 를 불러오는 코드를 지워주세요.
index.js
import React from 'react';
import ReactDOM from 'react-dom';
import './index.css';
import App from './App';
import * as serviceWorker from './serviceWorker';
ReactDOM.render(<App />, document.getElementById('root'));
// If you want your app to work offline and load faster, you can change
// unregister() to register() below. Note this comes with some pitfalls.
// Learn more about service workers: https://bit.ly/CRA-PWA
serviceWorker.unregister();
리덕스 모듈 만들기
이번에는, 리액트 프로젝트에 리덕스를 적용하기 위해서 리덕스 모듈을 만들어보도록 하겠습니다. 리덕스 모듈이란 다음 항목들이 모두 들어있는 자바스크립트 파일을 의미합니다.
- 액션 타입
- 액션 생성함수
- 리듀서
리덕스를 사용하기 위해 필요한 위 항목들은 각각 다른 파일에 저장 할 수도 있습니다.
리덕스 GitHub 레포에 등록되어있는 예제 프로젝트를 보면 다음과 같이 코드가 분리되어 있습니다.
- actions
- index.js
- reducers
- todos.js
- visibilityFilter.js
- index.js
위 예제 프로젝트에서는 액션과 리듀서가 서로 다른 파일에 정의되어있습니다. 하지만, 이 코드들이 꼭 분리되어있을 필요는 없습니다. 이 코드들이 서로 다른 디렉터리에, 그리고 서로 다른 파일에 분리가 되어있으면 개발을 하는데 꽤나 불편합니다. 그래서 우리는 리듀서와 액션 관련 코드들을 하나의 파일에 몰아서 작성해보도록 하겠습니다. 이를 Ducks 패턴이라고 부릅니다. 리덕스 관련 코드를 분리하는 방식은 정해져있는 방식이 없으므로 나중에는 여러분이 자유롭게 분리하셔도 상관없습니다.
counter 모듈 만들기
자! 그러면 리덕스 모듈을 만들어볼까요? 첫번째 모듈은 counter 모듈입니다.
src 디렉터리에 modules 디렉터리를 만들고, 그 안에 counter.js 파일을 생성하여 다음 코드를 작성해주세요.
modules/counter.js
/* 액션 타입 만들기 */
// Ducks 패턴을 따를땐 액션의 이름에 접두사를 넣어주세요.
// 이렇게 하면 다른 모듈과 액션 이름이 중복되는 것을 방지 할 수 있습니다.
const SET_DIFF = 'counter/SET_DIFF';
const INCREASE = 'counter/INCREASE';
const DECREASE = 'counter/DECREASE';
/* 액션 생성함수 만들기 */
// 액션 생성함수를 만들고 export 키워드를 사용해서 내보내주세요.
export const setDiff = diff => ({ type: SET_DIFF, diff });
export const increase = () => ({ type: INCREASE });
export const decrease = () => ({ type: DECREASE });
/* 초기 상태 선언 */
const initialState = {
number: 0,
diff: 1
};
/* 리듀서 선언 */
// 리듀서는 export default 로 내보내주세요.
export default function counter(state = initialState, action) {
switch (action.type) {
case SET_DIFF:
return {
...state,
diff: action.diff
};
case INCREASE:
return {
...state,
number: state.number + state.diff
};
case DECREASE:
return {
...state,
number: state.number - state.diff
};
default:
return state;
}
}
todos 모듈 만들기
todos 모듈도 만들어봅시다. modules 디렉터리에 todos.js 파일을 다음과 같이 작성해주세요.
modules/todos.js
/* 액션 타입 선언 */
const ADD_TODO = 'todos/ADD_TODO';
const TOGGLE_TODO = 'todos/TOGGLE_TODO';
/* 액션 생성함수 선언 */
let nextId = 1; // todo 데이터에서 사용 할 고유 id
export const addTodo = text => ({
type: ADD_TODO,
todo: {
id: nextId++, // 새 항목을 추가하고 nextId 값에 1을 더해줍니다.
text
}
});
export const toggleTodo = id => ({
type: TOGGLE_TODO,
id
});
/* 초기 상태 선언 */
// 리듀서의 초기 상태는 꼭 객체타입일 필요 없습니다.
// 배열이여도 되고, 원시 타입 (숫자, 문자열, 불리언 이여도 상관 없습니다.
const initialState = [
/* 우리는 다음과 같이 구성된 객체를 이 배열 안에 넣을 것입니다.
{
id: 1,
text: '예시',
done: false
}
*/
];
export default function todos(state = initialState, action) {
switch (action.type) {
case ADD_TODO:
return state.concat(action.todo);
case TOGGLE_TODO:
return state.map(
todo =>
todo.id === action.id // id 가 일치하면
? { ...todo, done: !todo.done } // done 값을 반전시키고
: todo // 아니라면 그대로 둠
);
default:
return state;
}
}
루트 리듀서 만들기
우리는 현재 두가지의 리덕스 모듈을 만들었습니다. 그래서 리듀서도 두개죠. 한 프로젝트에 여러개의 리듀서가 있을때는 이를 한 리듀서로 합쳐서 사용합니다. 합쳐진 리듀서를 우리는 루트 리듀서라고 부릅니다.
리듀서를 합치는 작업은 리덕스에 내장되어있는 combineReducers라는 함수를 사용합니다.
modules 디렉터리에 index.js 를 만들고 다음과 같이 코드를 작성해보세요.
modules/index.js
import { combineReducers } from 'redux';
import counter from './counter';
import todos from './todos';
const rootReducer = combineReducers({
counter,
todos
});
export default rootReducer;
index.js
import React from 'react';
import ReactDOM from 'react-dom';
import './index.css';
import App from './App';
import * as serviceWorker from './serviceWorker';
import { createStore } from 'redux';
import rootReducer from './modules';
const store = createStore(rootReducer); // 스토어를 만듭니다.
console.log(store.getState()); // 스토어의 상태를 확인해봅시다.
ReactDOM.render(<App />, document.getElementById('root'));
serviceWorker.unregister();
상태가 잘 출력된것을 확인하셨다면 console.log(store.getState()); 코드는 이제 지워주세요.
리액트 프로젝트에 리덕스 적용하기
$ yarn add react-redux
그 다음에는 index.js 에서 Provider라는 컴포넌트를 불러와서 App 컴포넌트를 감싸주세요. 그리고 Provider의 props 에 store 를 넣어주세요.
import React from 'react';
import ReactDOM from 'react-dom';
import './index.css';
import App from './App';
import * as serviceWorker from './serviceWorker';
import { createStore } from 'redux';
import { Provider } from 'react-redux';
import rootReducer from './modules';
const store = createStore(rootReducer); // 스토어를 만듭니다.
ReactDOM.render(
<Provider store={store}>
<App />
</Provider>,
document.getElementById('root')
);
serviceWorker.unregister();
Provider로 store를 넣어서 App 을 감싸게 되면 우리가 렌더링하는 그 어떤 컴포넌트던지 리덕스 스토어에 접근 할 수 있게 된답니다.
카운터 구현하기
프리젠테이셔널 컴포넌트 만들기
프리젠테이셔널 컴포넌트란, 리덕스 스토어에 직접적으로 접근하지 않고 필요한 값 또는 함수를 props 로만 받아와서 사용하는 컴포넌트입니다.
Counter.js
import React from 'react';
function Counter({ number, diff, onIncrease, onDecrease, onSetDiff }) {
const onChange = e => {
// e.target.value 의 타입은 문자열이기 때문에 숫자로 변환해주어야 합니다.
onSetDiff(parseInt(e.target.value, 10));
};
return (
<div>
<h1>{number}</h1>
<div>
<input type="number" value={diff} min="1" onChange={onChange} />
<button onClick={onIncrease}>+</button>
<button onClick={onDecrease}>-</button>
</div>
</div>
);
}
export default Counter;
프리젠테이셔널 컴포넌트에선 주로 이렇게 UI를 선언하는 것에 집중하며, 필요한 값들이나 함수는 props 로 받아와서 사용하는 형태로 구현합니다.
컨테이너 컴포넌트 만들기
컨테이너 컴포넌트란, 리덕스 스토어의 상태를 조회하거나, 액션을 디스패치 할 수 있는 컴포넌트를 의미합니다. 그리고, HTML 태그들을 사용하지 않고 다른 프리젠테이셔널 컴포넌트들을 불러와서 사용합니다.
CounterContainer.js
import React from 'react';
import { useSelector, useDispatch } from 'react-redux';
import Counter from '../components/Counter';
import { increase, decrease, setDiff } from '../modules/counter';
function CounterContainer() {
// useSelector는 리덕스 스토어의 상태를 조회하는 Hook입니다.
// state의 값은 store.getState() 함수를 호출했을 때 나타나는 결과물과 동일합니다.
const { number, diff } = useSelector(state => ({
number: state.counter.number,
diff: state.counter.diff
}));
// useDispatch 는 리덕스 스토어의 dispatch 를 함수에서 사용 할 수 있게 해주는 Hook 입니다.
const dispatch = useDispatch();
// 각 액션들을 디스패치하는 함수들을 만드세요
const onIncrease = () => dispatch(increase());
const onDecrease = () => dispatch(decrease());
const onSetDiff = diff => dispatch(setDiff(diff));
return (
<Counter
// 상태와
number={number}
diff={diff}
// 액션을 디스패치 하는 함수들을 props로 넣어줍니다.
onIncrease={onIncrease}
onDecrease={onDecrease}
onSetDiff={onSetDiff}
/>
);
}
export default CounterContainer;
이제 App 컴포넌트에서 CounterContainer를 불러와서 렌더링하세요.
import React from 'react';
import CounterContainer from './containers/CounterContainer';
function App() {
return (
<div>
<CounterContainer />
</div>
);
}
export default App;
+, - 버튼을 눌러보세요. 그리고 input 의 숫자도 변경해본다음에 또 +, - 버튼을 눌러보세요.
프리젠테이셔널 컴포넌트와 컨테이너 컴포넌트
우리가 이번에 리액트 컴포넌트에서 리덕스를 사용 할 때 프리젠테이셔널 컴포넌트와 컨테이너 컴포넌트를 분리해서 작업을 했습니다. 이러한 패턴을 리덕스의 창시자 Dan Abramov가 소개하게 되면서 이렇게 컴포넌트들을 구분지어서 진행하는게 당연시 됐었습니다.
하지만, 꼭 이렇게 하실 필요는 없습니다. Dan Abramov 또한 2019년에 자신이 썼던 포스트를 수정하게 되면서 꼭 이런 형태로 할 필요는 없다고 명시하였습니다.
순전히 여러분이 편하다고 생각하는 방식을 택하시면 됩니다.
저는 개인적으로 프리젠테이셔널 / 컨테이너 컴포넌트를 구분지어서 작성하긴 하지만 디렉터리 상으로는 따로 구분 짓지 않는 것을 선호합니다.
리덕스 개발자도구 적용하기
리덕스 개발자 도구를 사용하면 현재 스토어의 상태를 개발자 도구에서 조회 할 수 있고 지금까지 어떤 액션들이 디스패치 되었는지, 그리고 액션에 따라 상태가 어떻게 변화했는지 확인 할 수 있습니다. 추가적으로, 액션을 직접 디스패치 할 수도 있답니다.
우선 크롬 웹 스토어 에서 확장 프로그램을 설치해주세요. (참고로 파이어폭스에서도 사용 가능합니다.)
$ yarn add redux-devtools-extension
index.js
import React from 'react';
import ReactDOM from 'react-dom';
import './index.css';
import App from './App';
import * as serviceWorker from './serviceWorker';
import { createStore } from 'redux';
import { Provider } from 'react-redux';
import rootReducer from './modules';
import { composeWithDevTools } from 'redux-devtools-extension'; // 리덕스 개발자 도구
const store = createStore(rootReducer, composeWithDevTools()); // 스토어를 만듭니다.
// composeWithDevTools 를 사용하여 리덕스 개발자 도구 활성화
ReactDOM.render(
<Provider store={store}>
<App />
</Provider>,
document.getElementById('root')
);
serviceWorker.unregister();
할 일 목록 구현하기
프리젠테이셔널 컴포넌트 구현하기
먼저 Todos 라는 프리젠테이셔널 컴포넌트를 구현하겠습니다. components 디렉터리에 Todos.js 파일을 생성하세요.
이 파일에는 TodoItem, TodoList, Todos 이렇게 총 3가지의 컴포넌트를 작성 할 것입니다. 이렇게 여러개의 컴포넌트를 만드는 이유는 컴포넌트의 리렌더링 성능을 최적화하기 위함입니다. 지금은 편의상 한 파일에 모두 작성을 할건데, 취향에 따라 각각 다른 파일에 분리하셔도 상관 없습니다.
components/Todos.js
import React, { useState } from 'react';
// 컴포넌트 최적화를 위하여 React.memo를 사용합니다
const TodoItem = React.memo(function TodoItem({ todo, onToggle }) {
return (
<li
style={{ textDecoration: todo.done ? 'line-through' : 'none' }}
onClick={() => onToggle(todo.id)}
>
{todo.text}
</li>
);
});
// 컴포넌트 최적화를 위하여 React.memo를 사용합니다
const TodoList = React.memo(function TodoList({ todos, onToggle }) {
return (
<ul>
{todos.map(todo => (
<TodoItem key={todo.id} todo={todo} onToggle={onToggle} />
))}
</ul>
);
});
function Todos({ todos, onCreate, onToggle }) {
// 리덕스를 사용한다고 해서 모든 상태를 리덕스에서 관리해야하는 것은 아닙니다.
const [text, setText] = useState('');
const onChange = e => setText(e.target.value);
const onSubmit = e => {
e.preventDefault(); // Submit 이벤트 발생했을 때 새로고침 방지
onCreate(text);
setText(''); // 인풋 초기화
};
return (
<div>
<form onSubmit={onSubmit}>
<input
value={text}
placeholder="할 일을 입력하세요.."
onChange={onChange}
/>
<button type="submit">등록</button>
</form>
<TodoList todos={todos} onToggle={onToggle} />
</div>
);
}
export default Todos;
컨테이너 컴포넌트 만들기
import React, { useCallback } from 'react';
import { useSelector, useDispatch } from 'react-redux';
import Todos from '../components/Todos';
import { addTodo, toggleTodo } from '../modules/todos';
function TodosContainer() {
// useSelector 에서 꼭 객체를 반환 할 필요는 없습니다.
// 한 종류의 값만 조회하고 싶으면 그냥 원하는 값만 바로 반환하면 됩니다.
const todos = useSelector(state => state.todos);
const dispatch = useDispatch();
const onCreate = text => dispatch(addTodo(text));
const onToggle = useCallback(id => dispatch(toggleTodo(id)), [dispatch]); // 최적화를 위해 useCallback 사용
return <Todos todos={todos} onCreate={onCreate} onToggle={onToggle} />;
}
export default TodosContainer;
App.js
import React from 'react';
import CounterContainer from './containers/CounterContainer';
import TodosContainer from './containers/TodosContainer';
function App() {
return (
<div>
<CounterContainer />
<hr />
<TodosContainer />
</div>
);
}
export default App;
useSelector 최적화
리액트 컴포넌트에서 리덕스 상태를 조회해서 사용 할 때 최적화를 하기 위해서 어떤 사항을 고려해야 하는지 알아보도록 하겠습니다.
지난 섹션에서 할 일 목록을 만들 때에는 프리젠테이셔널 컴포넌트에서 React.memo를 사용하여 리렌더링 최적화를 해줬었는데요, 컨테이너 컴포넌트에서는 어떤 것들을 검토해야 하는지 알아보겠습니다.
우선, 리액트 개발자 도구의 톱니바퀴를 눌러서 Highlight Updates 를 체크하세요.
그리고 나서 카운터의 +, - 를 눌러보시면 하단의 할 일 목록이 리렌더링되진 않지만 할 일 목록의 항목을 토글 할 때에는 카운터가 리렌더링되는 것을 확인 할 수 있습니다.
기본적으로, useSelector를 사용해서 리덕스 스토어의 상태를 조회 할 땐 만약 상태가 바뀌지 않았으면 리렌더링하지 않습니다.
TodosContainer 의 경우 카운터 값이 바뀔 때 todos 값엔 변화가 없으니까, 리렌더링되지 않는것이죠.
const todos = useSelector(state => state.todos);
const { number, diff } = useSelector(state => ({
number: state.counter.number,
diff: state.counter.diff
}));
CounterContainer에서는 사실상 useSelector Hook 을 통해 매번 렌더링 될 때마다 새로운 객체 { number, diff }를 만드는 것이기 때문에 상태가 바뀌었는지 바뀌지 않았는지 확인을 할 수 없어서 낭비 렌더링이 이루어지고 있는 것 입니다.
이를 최적화 하기 위해선 두가지 방법이 있습니다.
첫번째는, useSelector 를 여러번 사용하는 것 입니다.
const number = useSelector(state => state.counter.number);
const diff = useSelector(state => state.counter.diff);
이렇게 하면 해당 값들 하나라도 바뀌었을 때에만 컴포넌트가 리렌더링 됩니다.
두번째는, react-redux의 shallowEqual 함수를 useSelector의 두번째 인자로 전달해주는 것 입니다.
import React from 'react';
import { useSelector, useDispatch, shallowEqual } from 'react-redux';
import Counter from '../components/Counter';
import { increase, decrease, setDiff } from '../modules/counter';
function CounterContainer() {
// useSelector는 리덕스 스토어의 상태를 조회하는 Hook입니다.
// state의 값은 store.getState() 함수를 호출했을 때 나타나는 결과물과 동일합니다.
const { number, diff } = useSelector(
state => ({
number: state.counter.number,
diff: state.counter.diff
}),
shallowEqual
);
(...)
useSelector 의 두번째 파라미터는 equalityFn 인데요,
equalityFn?: (left: any, right: any) => boolean
이전 값과 다음 값을 비교하여 true가 나오면 리렌더링을 하지 않고 false가 나오면 리렌더링을 합니다.
shallowEqual은 react-redux에 내장되어있는 함수로서, 객체 안의 가장 겉에 있는 값들을 모두 비교해줍니다.
여기서 겉에 있는 값이란, 만약 다음과 같은 객체가 있다면
const object = {
a: {
x: 3,
y: 2,
z: 1
},
b: 1,
c: [{ id: 1 }]
}
가장 겉에 있는 값은 object.a, object.b, object.c 입니다. shallowEqual 에서는 해당 값들만 비교하고 object.a.x 또는 object.c[0] 값은 비교하지 않습니다.
connect 함수
소개
connect 함수는 컨테이너 컴포넌트를 만드는 또 다른 방법입니다. 이 함수는 사실 앞으로 사용 할 일이 별로 없습니다. useSelector, useDispatch가 워낙 편하기 때문이죠. 때문에 이 내용은 이렇게 리덕스 튜토리얼의 후반부에서 다루게 되었습니다.
솔직히 말씀드리자면 앞으로 여러분이 리덕스를 사용하게 될 때에는 connect 함수를 더 이상 사용하게 될 일이 그렇게 많지 않을 것입니다.
우리가 리액트 컴포넌트를 만들 때에는 함수형 컴포넌트로 만드는 것을 우선시해야 하고, 꼭 필요할 때에만 클래스형 컴포넌트로 작성을 해야 합니다. 만약 클래스형 컴포넌트로 작성을 하게 되는 경우에는 Hooks 를 사용하지 못하기 때문에 connect 함수를 사용하셔야 됩니다.
추가적으로, 2019년 이전에 작성된 리덕스와 연동된 컴포넌트들은 connect 함수로 작성되었을 것입니다. connect함수가 사라지는 것은 아니기 때문에 옛날에 만든 컨테이너 컴포넌트들을 함수형으로 변환을 하실 필요는 없습니다. 때문에, 여러분들이 나중에 리액트 프로젝트를 유지보수하게 될 일이 있다면 connect 함수를 종종 접하게 될 수 있습니다.
HOC란?
connect는 HOC입니다. HOC란, Higher-Order Component 를 의미하는데요, 이는 리액트 컴포넌트를 개발하는 하나의 패턴으로써, 컴포넌트의 로직을 재활용 할 때 유용한 패턴입니다. 예를 들어서, 특정 함수 또는 값을 props 로 받아와서 사용하고 싶은 경우에 이러한 패턴을 사용합니다. 리액트에 Hook이 도입되기 전에는 HOC 패턴이 자주 사용되어왔으나, 리액트에 Hook 이 도입된 이후에는 HOC를 만들 이유가 없어졌습니다. 대부분의 경우 Hook으로 대체 할 수 있기 때문이지요. 심지어, 커스텀 Hook을 만드는건 굉장히 쉽기도 합니다.
HOC를 직접 구현하게 되는 일은 거의 없기 때문에 지금 시점에 와서 HOC를 직접 작성하는 방법을 배워보거나, 이해하기 위해 시간을 쏟을 필요는 없습니다.
HOC의 용도는 "컴포넌트를 특정 함수로 감싸서 특정 값 또는 함수를 props로 받아와서 사용 할 수 있게 해주는 패턴"이라는 것 정도만 알아두시면 됩니다.
HOC 컬렉션 라이브러리인 recompose라는 라이브러리를 보시면 HOC를 통해서 어떤 것을 하는지 갈피를 잡을 수 있습니다.
const enhance = withState('counter', 'setCounter', 0)
const Counter = enhance(({ counter, setCounter }) =>
<div>
Count: {counter}
<button onClick={() => setCounter(n => n + 1)}>Increment</button>
<button onClick={() => setCounter(n => n - 1)}>Decrement</button>
</div>
)
어디선가 많이 본 느낌이죠? 마치 useState 같습니다. withState 함수를 사용해서 enhance라는 컴포넌트에 props로 특정 값과 함수를 넣어주는 함수를 만들었습니다. 그리고, 컴포넌트를 만들 때 enhance로 감싸주는 원하는 값과 함수를 props를 통하여 사용 할 수 있게 됩니다.
connect 사용해보기
connect 함수는 리덕스 스토어안에 있는 상태를 props로 넣어줄수도있고, 액션을 디스패치하는 함수를 props 로 넣어줄수도 있습니다.
containers/CounterContainer.js
import React from 'react';
import { connect } from 'react-redux';
import Counter from '../components/Counter';
import { increase, decrease, setDiff } from '../modules/counter';
function CounterContainer({ number, diff, onIncrease, onDecrease, onSetDiff }) {
return (
<Counter
// 상태와
number={number}
diff={diff}
// 액션을 디스패치 하는 함수들을 props로 넣어줍니다.
onIncrease={onIncrease}
onDecrease={onDecrease}
onSetDiff={onSetDiff}
/>
);
}
// mapStateToProps 는 리덕스 스토어의 상태를 조회해서 어떤 것들을 props 로 넣어줄지 정의합니다.
// 현재 리덕스 상태를 파라미터로 받아옵니다.
const mapStateToProps = state => ({
number: state.counter.number,
diff: state.counter.diff
});
// mapDispatchToProps 는 액션을 디스패치하는 함수를 만들어서 props로 넣어줍니다.
// dispatch 를 파라미터로 받아옵니다.
const mapDispatchToProps = dispatch => ({
onIncrease: () => dispatch(increase()),
onDecrease: () => dispatch(decrease()),
onSetDiff: diff => dispatch(setDiff(diff))
});
// connect 함수에는 mapStateToProps, mapDispatchToProps 를 인자로 넣어주세요.
export default connect(
mapStateToProps,
mapDispatchToProps
)(CounterContainer);
/* 위 코드는 다음과 동일합니다.
const enhance = connect(mapStateToProps, mapDispatchToProps);
export defualt enhance(CounterContainer);
*/
카운터가 잘 작동하는지 확인해보세요.
mapStateToProps는 컴포넌트에 props로 넣어줄 리덕스 스토어 상태에 관련된 함수이고, mapDispatchToProps는 컴포넌트에 props로 넣어줄 액션을 디스패치하는 함수들에 관련된 함수입니다.
containers/CounterContainer.js
import React from 'react';
import { bindActionCreators } from 'redux';
import { connect } from 'react-redux';
import Counter from '../components/Counter';
import { increase, decrease, setDiff } from '../modules/counter';
// 액션 생성함수 이름이 바뀌어서 props 이름도 바뀌었습니다.
// 예: onIncrease -> increase
function CounterContainer({ number, diff, increase, decrease, setDiff }) {
return (
<Counter
// 상태와
number={number}
diff={diff}
// 액션을 디스패치 하는 함수들을 props로 넣어줍니다.
onIncrease={increase}
onDecrease={decrease}
onSetDiff={setDiff}
/>
);
}
// mapStateToProps 는 리덕스 스토어의 상태를 조회해서 어떤 것들을 props 로 넣어줄지 정의합니다.
// 현재 리덕스 상태를 파라미터로 받아옵니다.
const mapStateToProps = state => ({
number: state.counter.number,
diff: state.counter.diff
});
// mapDispatchToProps 는 액션을 디스패치하는 함수를 만들어서 props로 넣어줍니다.
// dispatch 를 파라미터로 받아옵니다.
const mapDispatchToProps = dispatch =>
// bindActionCreators 를 사용하면, 자동으로 액션 생성 함수에 dispatch 가 감싸진 상태로 호출 할 수 있습니다.
bindActionCreators(
{
increase,
decrease,
setDiff
},
dispatch
);
// connect 함수에는 mapStateToProps, mapDispatchToProps 를 인자로 넣어주세요.
export default connect(
mapStateToProps,
mapDispatchToProps
)(CounterContainer);
/* 위 코드는 다음과 동일합니다.
const enhance = connect(mapStateToProps, mapDispatchToProps);
export defualt enhance(CounterContainer);
*/
containers/CounterContainer.js
import React from 'react';
import { connect } from 'react-redux';
import Counter from '../components/Counter';
import { increase, decrease, setDiff } from '../modules/counter';
function CounterContainer({ number, diff, increase, decrease, setDiff }) {
return (
<Counter
// 상태와
number={number}
diff={diff}
// 액션을 디스패치 하는 함수들을 props로 넣어줍니다.
onIncrease={increase}
onDecrease={decrease}
onSetDiff={setDiff}
/>
);
}
// mapStateToProps 는 리덕스 스토어의 상태를 조회해서 어떤 것들을 props 로 넣어줄지 정의합니다.
// 현재 리덕스 상태를 파라미터로 받아옵니다.
const mapStateToProps = state => ({
number: state.counter.number,
diff: state.counter.diff
});
// mapDispatchToProps가 함수가 아니라 객체면
// bindActionCreators 를 connect 에서 대신 해줍니다.
const mapDispatchToProps = {
increase,
decrease,
setDiff
};
// connect 함수에는 mapStateToProps, mapDispatchToProps 를 인자로 넣어주세요.
export default connect(
mapStateToProps,
mapDispatchToProps
)(CounterContainer);
/* 위 코드는 다음과 동일합니다.
const enhance = connect(mapStateToProps, mapDispatchToProps);
export defualt enhance(CounterContainer);
*/
connect 함수 더 깔끔하게 작성하기
취향에 따라 다르게 생각 할 수 있긴 하지만, mapStateToProps와 mapDispatchToProps를 따로 선언하지 않고 connect 함수를 사용 할 때 인자 쪽에서 익명함수로 바로 만들어서 사용하면 코드가 꽤나 깔끔해집니다.
containers/TodosContainer.js
import React, { useCallback } from 'react';
import { connect } from 'react-redux';
import Todos from '../components/Todos';
import { addTodo, toggleTodo } from '../modules/todos';
function TodosContainer({ todos, addTodo, toggleTodo }) {
const onCreate = text => addTodo(text);
const onToggle = useCallback(id => toggleTodo(id), [toggleTodo]); // 최적화를 위해 useCallback 사용
return <Todos todos={todos} onCreate={onCreate} onToggle={onToggle} />;
}
export default connect(
state => ({ todos: state.todos }),
{
addTodo,
toggleTodo
}
)(TodosContainer);
connect, 알아둬야 하는 것들
1. mapStateToProps 의 두번째 파라미터 ownProps
mapStateToProps에서는 두번째 파라미터 ownProps를 받아올 수 있는데요 이 파라미터는 생략해도 되는 파라미터입니다. 이 값은 우리가 컨테이너 컴포넌트를 렌더링 할때 직접 넣어주는 props 를 가르킵니다. 예를 들어서
<CounterContainer myValue={1} /> 이라고 하면 { myValue: 1 } 값이 ownProps가 되죠.
이 두번째 파라미터는 다음과 같은 용도로 활용 할 수 있습니다.
const mapStateToProps = (state, ownProps) => ({
todo: state.todos[ownProps.id]
})
리덕스에서 어떤 상태를 조회 할 지 설정하는 과정에서 현재 받아온 props에 따라 다른 상태를 조회 할 수 있죠.
2. connect 의 3번째 파라미터 mergeProps
mergeProps는 connect 함수의 세번째 파라미터이며, 생략해도 되는 파라미터입니다. 이 파라미터는 컴포넌트가 실제로 전달받게 될 props 를 정의합니다.
(stateProps, dispatchProps, ownProps) => Object
이 함수를 따로 지정하지 않으면 결과는 { ...ownProps, ...stateProps, ...dispatchProps } 입니다.
(사실상 사용하게 될 일이 없습니다.)
3. connect의 4번째 파라미터 options
connect 함수를 사용 할 때 이 컨테이너 컴포넌트가 어떻게 동작할지에 대한 옵션을 4번째 파라미터를 통해 설정 할 수 있습니다. 이는 생략해도 되는 파라미터입니다. 이 옵션들은 따로 커스터마이징하게 되는일이 별로 없습니다. 자세한 내용은 링크를 참조하세요. 이 옵션을 통하여 Context 커스터마이징, 최적화를 위한 비교 작업 커스터마이징, 및 ref 관련 작업을 할 수 있습니다.
'WINK-(Web & App) > React.js 스터디' 카테고리의 다른 글
[2024-2 React.js 스터디] 이서영 #5주차 (0) | 2024.11.28 |
---|---|
[2024-2 React.js 스터디] 윤아영 #5주차 (0) | 2024.11.27 |
[2024-2 React.js 스터디 ] 김지수 #5주차 (1) | 2024.11.19 |
[2024-2 React.js 스터디] 이서영 #4주차 (0) | 2024.11.13 |
[2024-2 React.js 스터디 ] 김지수 #4주차 (0) | 2024.11.13 |