리덕스의 창시자인 댄 아브라모프가 직접 만든 동영상 강의를 요약해보았다.
강의 링크 : https://egghead.io/courses/getting-started-with-redux
- 전체 앱의 상태는 오직 하나의 객체로 관리한다. Single source of truth
- 상태 변화는 Action 을 통해서만 변경 가능하고 Store 자체는 읽기 전용으로 직접 수정하지 않는다.
- Action 은 무슨 일이 일어났는지를 의미하는 하나의
plain object
이다. 오직 이 Action 객체들을 통해서만 상태의 변화를 나타냄으로써 얻게 되는 이득은 다음과 같다. - It also enables very powerful developer tools, because it is possible to trace every mutation to the action that caused it. You can record user sessions and reproduce them just by replaying every action. - 리덕스 튜토리얼 중
- Action 은 무슨 일이 일어났는지를 의미하는 하나의
- 상태를 변화시키는 주체로써 이전의 상태와 디스패치된 액션만을 토대로 새로운 상태를 만들어내는 리듀서라는 함수를 필요로 한다. 리듀서는 순수함수여야 한다.
- 이전 상태로써 전달된 state 매개변수가 undefined 일 때는 상태 객체의 초기값을 반환하는 convention이 있다.
- 정해진 action.type 이 아닌 경우 현재의 상태를 반환한다.
- 순수 함수는 전달받은 매개변수를 직접 변경하지 않는다. 새로운 상태 객체를 생성하는 형태로 불변성을 지킨다.
- 항상 새로운 객체를 만들어내지만 isLoading 이라는 상태만을 변경할 시 todo list의 todos array는 기존의 것을 재참조(재활용) 하므로(변경이 필요한 상태값만을 새로 생성) 오버헤드가 크지 않다.
/* 간단한 리듀서의 예시
이전의 상태값과 액션을 매개변수로 받아서 이를 기반으로 새로운 상태 객체를 생성하여 반환한다.
상태 변경에 관련된 비즈니스 로직을 담고 있다.
왜 이렇게 불변성을 지키는 순수함수의 형태로 구현해야 할까?
리액트 돔을 이용해 렌더링을 할 시
객체 형태의 상태를 얕은 비교만으로 변경여부를 감지하고 업데이트 여부를 결정할 수 있기 때문.
또한, 전달된 인자에만 의존해 출력이 결정되고, 동일한 인자에 같은 결과를 나타낸다면,
테스트에 유리하고 메모이제이션과 같은 성능 최적화 기법을 이용할 수 있으며,
외부의 상태에 의존하지 않기 때문에 병렬화된 무언가를 하기 용이하기 때문이다.
상태값이 아래와 같이 원시 타입의 형태일 경우 자동으로 불변성이 지켜지지만,
객체 타입의 경우 새로운 객체를 생성하는 방법을 따로 고안해야한다.
보통 spread syntax와 함께 배열의 filter나 map 같은 불변성을 지켜주는 함수를 사용하지만,
불변성을 지키기 위한 코드가 복잡해질 때에는 immer와 같은 라이브러리를 활용할 수 있다.
immer의 경우 라이브러리에서 default export로 제공되는 함수를 이용해,
원본 객체와 콜백 함수를 인자로 전달하는 형태로 사용하며,
콜백 함수 내에서 전달받은 객체를 직접 조작하는 형태의 로직을 작성하여도
종국에는 마치 복제양 돌리와 같은 새로운 객체를 반환한다.
내부적으로 Proxy를 사용한다고 하는데,
단순히 접근 연산을 원본 객체로 reflect 하는 Proxy 객체를 반환하는 형태인 것인지 내부 구조가 궁금하다.
아마도 브라우저 호환성을 위해(Proxy 사용이 불가할 때) 이런저런 전문적인 코드들이 잔뜩 덧붙여져 있겠지..
*/
const counter = (state = 0 /* 이전 상태값이 존재하지 않을 때 */, action) => {
switch (action.type) {
case 'INCREMENT':
return state + 1;
case 'DECREMENT':
return state - 1;
default:
return state; // action.type 이 unknown 일 때 현재의 상태값 반환
}
}
- createStore(aReducer); - Store 를 만든다. 해당 Store의 변경 로직을 담당할 리듀서를 인자로 넘겨 배정한다.
- getState() - 현재의 상태 객체를 반환한다.
- dispatch(aAction) - 애플리케이션의 상태를 변경하기 위한 action을 dispatch 한다.
- subscribe(callbackFunction); - 어떤 action이 dispatch 되면 호출될 콜백 함수를 통해 상태의 업데이트를 구독한다. 이 콜백에는 현재의 상태를 기반으로 애플리케이션을 다시 렌더링하는 로직이 담겨져 있다.
const createStore = (reducer) => {
let state;
let listeners = [];
const getState = () => state;
const dispatch = (action) => {
state = reducer(state, action);
listeners.forEach(listener => listener());
}
const subscribe = (listener) => {
listeners.push(listener);
return () => { // unsubscribe function
listeners = listeners.filter(l => l !== listener);
}
}
dispatch({});
return { getState, dispatch, subscribe };
}
// 상기된 코드들은 생략
const store = createStore(counter);
/* 리덕스 스토어로 action을 dispatch 하는 콜백 함수들 */
const onIncrement = () => {
store.dispatch({ type: 'INCREMENT' });
}
const onIncrement = () => {
store.dispatch({ type: 'DECREMENT' });
}
const render = () => {
ReactDOM.render(
<Counter value={ store.getState() }
onIncrement={ onIncrement }
onDecremnt={ onDecrement } />
); // Action이 dispatch 될 때 마다 다시 렌더링된다.
};
/* a Dumb Component - 어떤 비즈니스 로직도 내장하지 않고,
상태를 변경하는 dispatch를 내장한 callback을
이벤트 핸들러로써 binding 하고 전달받은 현재의 상태값을 렌더링 가능한
형태로 변환하여 반환하는 역할만을 한다. */
const Counter = ({ value, onIncrement, onDecrement }) => {
return (
<div>
<h1>{ value }</h1>
<button onClick={onIncrement}>+</button>
<button onClick={onDecrement}>-</button>
</div>
);
};
render();
store.subscribe(render);
/*
여러개의 카운터를 가진 앱을 가정하고, 모든 카운터의 상태를 저장하기 위해 배열을 사용함을 전제로 한다.
[0, 0, 0] <- [첫번째 카운터 상태값, 두번째 카운터 상태값, 세번째 카운터 상태값]
dan은 강의에서 테스트 코드를 작성하면서 deepFreeze 라이브러리의 도움을 받는다.
이는 Object.freeze와 같은 동작을 하는 것으로 사료되며,
Object.preventExtensions와 Object.seal 을 동시 적용한 것과 같다.
*/
const addCounter = (list) => [...list, 0];
const removeCounter = (list, index) => [...list.slice(0, index), ...list.slice(index + 1)];
const removeCounterByFilter = (list, index) => list.filter((_v, i) => i !== index);
const incrementCounter = (list, index) => [...list.slice(0, index), ++list[index], ...list.slice(index + 1)];
const decrementCounter = (list, index) => [...list.slice(0, index), --list[index], ...list.slice(index + 1)];
// 2차 고차함수 형태로
const updateCounter = (cb) => (list, index) => list.map((v, i) => i === index ? cb(v) : v);
const incrementCounterByMap = updateCounter(v => v + 1); // pointer가 없는 함수
const decrementCounterByMap = updateCounter(v => v - 1);
/*
todo 의 상태 객체와 아래와 같은 형태임을 전제로 한다.
[
{
id: 0,
text: 'Learn Redux',
completed: false;
}
]
*/
const addTodo = (todos, todo) => [...todos, todo];
const removeTodo = (todos, id) => todos.filter((todo) => todo.id !== id);
// 업데이트 부분은 고차함수 형태로 각색해봤다.
const updateTodo = (cb, key) => (todos, index, value) =>
todos.map((todo) =>
todo.id === index ? { ...todo, ...{ [key]: cb(value ?? todo[key]) } } : v
);
const updateTodoText = updateTodo((v) => v, "text");
const toggleTodo = updateTodo((v) => v != null ? !v : false, "completed");
const todosReducer = (state = [], action) => {
switch (action.type) {
case "ADD_TODO":
return addTodo(state, action.todo);
case "REMOVE_TODO":
return removeTodo(state, action.id);
case "TOGGLE_TODO":
return toggleTodo(state, action.id);
case "UPDATE_TODO_TEXT":
return updateTodoText(state, action.id, action.value);
default:
return state;
}
}
- In a typical Redux app, there is just a single store with a single root reducing function. As your app grows, you split the root reducer into smaller reducers independently operating on the different parts of the state tree. This is exactly like how there is just one root component in a React app, but it is composed out of many small components.
const addTodo = (todos, todo) => [...todos, todo];
const removeTodo = (todos, id) => todos.filter((todo) => todo.id !== id);
// 업데이트 부분은 고차함수 형태로 각색해봤다.
const updateTodo = (cb, key) => (todos, index, value) =>
todos.map((todo) =>
todo.id === index ? { ...todo, ...{ [key]: cb(value ?? todo[key]) } } : todo
);
const updateTodoText = updateTodo((v) => v, "text");
const toggleTodo = updateTodo((v) => (v != null ? !v : false), "completed");
const todosReducer = (state = [], action) => {
switch (action.type) {
case "ADD_TODO":
return addTodo(state, action.todo);
case "REMOVE_TODO":
return removeTodo(state, action.id);
case "TOGGLE_TODO":
return toggleTodo(state, action.id);
case "UPDATE_TODO_TEXT":
return updateTodoText(state, action.id, action.value);
default:
return state;
}
};
const visibilityReducer = (state = "SHOW_ALL", action) => {
switch (action.type) {
case "SET_VISIBILITY_FILTER":
return action.filter;
default:
return state;
}
};
const todoAppReducer = (state = {}, action) => { // 각각의 부분적인 상태를 처리하는 리듀서들을 조합해 전체 상태를 처리하는 리듀서를 만든다.
return {
todos: todosReducer(state.todos, action),
visibility: visibilityReducer(state.visibility, action),
};
};
/*
스토어 구현
*/
const createStore = (reducer) => {
let state;
let listeners = [];
const getState = () => state;
const dispatch = (action) => {
state = reducer(state, action);
listeners.forEach((listener) => listener());
};
const subscribe = (listener) => {
listeners.push(listener);
return () => {
listeners = listeners.filter((l) => l !== listener);
};
};
return {
getState,
dispatch,
subscribe,
};
};
/*
리듀서 구현
*/
const addTodo = (todos, todo) => [...todos, todo];
const removeTodo = (todos, id) => todos.filter((todo) => todo.id !== id);
// 업데이트 부분은 고차함수 형태로 각색해봤다.
const updateTodo = (cb, key) => (todos, index, value) =>
todos.map((todo) =>
todo.id === index ? { ...todo, ...{ [key]: cb(value ?? todo[key]) } } : todo
);
const updateTodoText = updateTodo((v) => v, "text");
const toggleTodo = updateTodo((v) => (v != null ? !v : false), "completed");
const todosReducer = (state = [], action) => {
switch (action.type) {
case "ADD_TODO":
return addTodo(state, action.todo);
case "REMOVE_TODO":
return removeTodo(state, action.id);
case "TOGGLE_TODO":
return toggleTodo(state, action.id);
case "UPDATE_TODO_TEXT":
return updateTodoText(state, action.id, action.value);
default:
return state;
}
};
const visibilityReducer = (state = "SHOW_ALL", action) => {
switch (action.type) {
case "SET_VISIBILITY_FILTER":
return action.filter;
default:
return state;
}
};
const todoAppReducer = (state = {}, action) => {
return {
todos: todosReducer(state.todos, action),
visibility: visibilityReducer(state.visibility, action),
};
};
/*
액션 크리에이터들
*/
const ACTION_TYPES = {
ADD_TODO: "ADD_TODO",
REMOVE_TODO: "REMOVE_TODO",
TOGGLE_TODO: "TOGGLE_TODO",
UPDATE_TODO_TEXT: "UPDATE_TODO_TEXT",
SET_VISIBILITY_FILTER: "SET_VISIBILITY_FILTER",
};
const actionAddTodo = (todo) => ({ type: ACTION_TYPES.ADD_TODO, todo });
const actionRemoveTodo = (id) => ({ type: ACTION_TYPES.REMOVE_TODO, id });
const actionToggleTodo = (id) => ({ type: ACTION_TYPES.TOGGLE_TODO, id });
const actionUpdateTodo = (id, value) => ({
type: ACTION_TYPES.UPDATE_TODO_TEXT,
id,
value,
});
const actionSetVisibility = (filter) => ({
type: ACTION_TYPES.SET_VISIBILITY_FILTER,
filter,
});
/*
가상의 애플리케이션 코드
*/
const store = createStore(todoAppReducer);
store.subscribe(() => {
console.log(">>>", store.getState()); // action이 dispatch 되면 현재 상태를 콘솔에 출력
});
store.dispatch(
actionAddTodo({
id: 233,
text: "Hello World",
completed: false,
})
);
store.dispatch(
actionAddTodo({
id: 453,
text: "Bye, World",
completed: false,
})
);
store.dispatch(actionToggleTodo(233));
store.dispatch(actionUpdateTodo(453, "Long time no see"));
store.dispatch(actionSetVisibility("SHOW_DONE"));
상기된 리듀서 조합과 거의 같은 일을 한다.
combineReducers
함수에는 각 리듀서가 감당할 상태 트리의 필드 이름
과 해당 상태를 관리할 리듀서
를 객체 리터럴 형태로 전달 받는다.
만약, 각 리듀서의 이름과 필드 이름이 동일하다면 아래처럼 단축 표기법
을 사용해도 된다.
import { combineReducers } from Redux;
const todoReducer = combineReducers({ todos, visibility });
const combineReducers = (reducers) => {
return (state = {}, action) => {
const newState = {};
for (const stateKey in reducers) {
const reducer = reducers[stateKey];
const prevState = state[stateKey];
newState[stateKey] = reducer(prevState, action);
}
return newState;
};
};
const todoAppReducer = combineReducers({
todos: todosReducer,
visibility: visibilityReducer,
});