리덕스로 상태 관리하기

kangdari·2020년 8월 24일
10

리덕스(Redux)

리덕스는 자바스크립트를 위한 상태 관리 프레임워크다.

  • 컴포넌트 코드로부터 상태 관리 코드를 분리 할 수 있다.
  • 서버 렌더링 시 데이터 전달이 간편하다.
  • 로컬 스토리지에 데이터를 저장하고 불러오는 코드를 쉽게 작성할 수 있다.
  • 같은 상태 값을 다수의 컴포넌트에서 필요로 할 때 좋다.
  • 부모 컴포넌트에서 깊은 곳에 있는 자식 컴포넌트에 상태 값을 전달할 때 좋다.
  • 알림창과 같은 전역 컴포넌트의 상태 값을 관리할 때 좋다.
  • 페이지가 전환되어도 데이터는 살아 있어야 할 때 좋다.

세 가지 원칙

리덕스 사용 시 따라야할 세 가지 원칙이있다.

  1. 전체 상태 값을 하나의 객체에 저장한다.
  2. 상태 값은 불변 객체이다.
  3. 상태 값은 순수 함수에 의해서만 변경되어야 한다.
    • 부수 효과: 외부의 상태를 변경하는 것. 함수로 들어온 인자 값을 직적 변경하는 것.
    • 순수 함수: 부수 효과가 없는 함수. 즉, 동일한 인자값이 들어오면 항상 같은 결과 리턴.

하나의 객체에 프로그램의 전체 상태 값을 저장한다.

전체 상태 값을 하나의 자바스크립트 객체로 표현되기 때문에 활용도가 높아진다. 하지만 프로그램의 전체 상태 값을 리덕스로 관리하는 것은 쉬운 일이 아니므로 일부 상태만 리덕스를 활용해도 된다.

상태 값을 불변 객체로 관리한다.

상태 값은 오직 액션 객체에 의해서만 변경되어야 한다.

// 액션 객체
const incrementAction = {  
  type: 'INCREMENT',
  amount: 100,
};

store.dispatch(incrementAction);
  • 액션 객체는 type 속성 값이 존재하며, type 속성 값으로 액션 객체를 구분한다.
    type 속성 값을 제외한 나머지는 상태 값을 수정하기 위해 사용되는 정보다.
  • 액션 객체와 함께 dispatch 메서드를 호출하면 상태 값이 변경된다.

오직 순수 함수에 의해서만 상태 값을 변경해야 한다.

리덕스에서 상태 값을 변경하는 함수를 리듀서(reducer)라고 부른다.

(state, action) => nextState

리듀서는 이전 상태 값과 액션 객체를 입력받아 새로운 상태 값을 만드는 순수 함수다. 순수 함수는 부수 효과를 발생시키지 않아야 한다. 순수 함수는 같은 인수에 대해 항상 같은 값을 반환해야 한다.
(이런 특성때문에 순수 함수는 테스크 코드를 작성하기 쉽다.)

즉, 리듀서는 순수 함수이기 때문에 같은 상태 값과 액션 객체를 입력하면 항상 똑같은 상태 값을 반환한다.

리덕스 주요 개념

액션

action은 type 속성 값을 가진 자바스크립트 객체이다. 액션 객체를 dispatch 메서드에 넣어서 호출하면 리덕스는 상태 값을 변경하기 위해 위 그림의 과정을 수행한다.

// 액션 객체는 액션 생성 함수와 리듀서에서 액션 객체를 구분할 때도 사용되므로
// 상수 변수로 만드는게 좋다.
export const ADD = 'todo/ADD';
export const REMOVE = 'todo/REMOVE';
export const REMOVE_ALL = 'todo/REMOVE_ALL';

export function addTodo({ titile, priority }) {
  return { type: ADD, title, priroity };
}
export function removeTodo({ id }) {
  return { REMOVE, id };
}
export function removeAll() {
  return { REMOVE_ALL };
}

store.dispatch(addTodo({ title: '영화 보기', priority: 'high' }));
store.dispatch(removeTodo(12));
store.dispatch(removeAll());

액션 타입과 액션 생성 함수는 다른 코드나 외부에서 사용하므로 export 해준다.

미들웨어

미들웨어는 리듀서가 액션을 처리하기 전에 실행되는 함수다.

const myMiddleware = store => next => action => next(action);

미들웨어는 함수 세 개가 중첩된 구조로 되어있다.

import { createStore, applyMiddleware } from 'redux';

const midddleware_1 = (store) => (next) => (action) => {
  console.log('midddleware_1 start');
  const result = next(action); // next 함수 호출 시 리듀서 호출
  console.log('midddleware_1 end');
  return result;
};

const myReducer = (state, action) => {
  console.log('myReducer');
  return state;
};

const store = createStore(myReducer, applyMiddleware(midddleware_1));
store.dispatch({ type: 'SOME_ACTION' });
// midddleware_1 start
// myReducer
// midddleware_1 end

미들웨어 활용

  • 로그를 출력하는 미들웨어
const printLog = (store) => (next) => (action) => {
  console.log(`prev state = ${store.getState()}`);
  const result = next(action);
  console.log(`next state = ${store.getState()}`);
  return result;
};

next 함수를 호출 시 리듀서가 호출되고 상태 값이 변경된다. next 함수 호출 전후로 로그를 출력하고있다.

  • 실행을 연기할 수 있는 미들웨어
    액션 객체에 delay 정보가 있으면 delay만큼 연기한다. 반환된 함수를 호출 시 next 함수의 호출을 막을 수 있다.
const delayAction = (store) => (next) => (action) => {
  const delay = aciton.meta && action.meta.delay;
  if (!delay) return next(action);

  const timeoutId = setTimeout(() => next(action), delay);
  // 취소 함수 반환
  return function cancel() { 
    clearTimeout(timeoutId);
  };
};

const cancel = store.dispatch({
  type: 'SOME_ACTION',
  meta: { delay: 1000 },
});

cancel(); // 

리듀서

리듀서는 액션에 발생했을 때 새로운 상태 값을 만드는 순수 함수이다.

리듀서 함수 예시

function reducer(state = INITIAL_STATE, action) {
  switch (action.type) {
    // ...
    case REMOVE_ALL:
      // 새로운 객체 생성: 불변성 관리
      return {
        ...state,
        todos: [],
      };
    case REMOVE:
      return {
        ...state,
        todos: todos.filter((todo) => todo.id !== action.id),
      };
    // 처리할 액션이 없으면 상태값 변경 x
    default:
      return state;
  }
}
const INITIAL_STATE = { todos: [] };
  • 리덕스는 스토어를 생성할 때 상태 값이 없는 상태로 리듀서를 호출하므로 매개변수의 기본 값을 사용하여 초기 상태 값을 정의.
  • 각 액션의 type별로 case 문을 만들어 처리.
  • 상태 값은 불변 객체로 관리해야하므로 상태를 수정 할 때마다 새로운 객체를 생성.

중첩된 객체의 데이터 수정

function reducer(state = INITIAL_STATE, action) {
  switch (action.type) {
    case ADD:
      return {
        ...state,
        todos: [...state.todos, { id: getNewId(), title: action.title, priority: action.priority }],
    // ...
      };
  }
}
const INITIAL_STATE = { todos: [] };

todo 1개를 추가하기 위해 spread operator를 2번 사용하고있다. 만약 더 깊은 곳의 값을 수정할 때는 코드의 가독성이 많이 떨어진다. immer 패키지를 사용해 불변 객체를 관리할 수 있다.

immer를 이용해 리듀서 작성하기

import produce from 'immer';

const person = { name: 'kang', age: 27 };
const newPerson = produce(person, (draft) => {
  draft.age = 28;
});

produce 함수의 첫번째 매개변수로 변경하고자 하는 객체를 입력한다. 두번째 매개변수는 객체를 수정하는 함수다. draft가 person 객체라 생각하고 값을 수정해도 기존이 person 객체를 수정하지 않고 produce 함수가 새로운 객체를 반환해준다.

immer를 사용한 리듀서 함수 작성

import produce from 'immer';

function reducer(state = INITIAL_STATE, action) {
  return produce(state, (draft) => {
    switch (action.type) {
      case ADD:
        draft.todos.push(action.todo);
        break;
      case REMOVE_ALL:
        draft.todos = [];
        break;
      case REMOVE:
        draft.todos = draft.todos.filter((todo) => todo.id !== action.id);
        break;
      default:
        break;
    }
  });
}
const INITIAL_STATE = { todos: [] };
  • immer를 사용했기 때문에 push 메서드를 사용해도 기존의 상태 값을 직접 수정하지 않고 새로운 객체를 반환한다.

createReducer 함수로 리듀서 작성하기
createReducer 함수를 사용하면 간결하게 리듀서 작성이 가능하다.

const reducer = createReducer(INITIAL_STATE, {
  [ADD]: (state, action) => state.todos.push(action.todo),
  [REMOVE_ALL]: (state) => (state.todos = []),
  [REMOVE]: (state, action) => (state.todos = state.todos.filter((todo) => todo.id !== action.id)),
});

const INITIAL_STATE = { todos: [] };

createReducer 의 경우는 immer의 produce를 자체적으로 지원하기 때문에 따로 코드로 immutable 관리를 하지 않아도 되는 큰 장점이 있습니다.

스토어

스토어(store)는 리덕스의 상태 값을 가지는 객체다. 액션의 발생은 스토어의 dispatch 메서드로 시작된다. 스토어는 액션이 발생하면 미들웨어 함수를 실행하고 리듀서를 실행해서 상태 값을 새로운 값으로 변경한다.

데이터 종류별로 상태 값 나누기

상태값 나누기 예제를 위한 사전 작업

createReducer 파일 작성

import produce from 'immer';

export default function createReducer(initialState, handlerMap) {
  return function (state = initialState, action) {
    return produce(state, (draft) => {
      const handler = handlerMap[action.type];
      if (handler) {
        handler(draft, action);
      }
    });
  };
}

친구 목록을 위한 리덕스 코드 작성 (ducks 패턴)

import createReducer from '../Common/createReducer';

// 액션 타입
const ADD = 'friend/ADD';
const REMOVE = 'friend/REMOVE';
const EDIT = 'firend/EDIT';

// 액션 생성자 함수
export const addFriend = (friend) => ({ type: ADD, friend });
export const removeFriend = (friend) => ({ type: REMOVE, friend });
export const editFriend = (friend) => ({ type: EDIT, friend });

// 초기 상태
const INITIAL_STATE = { friends: [] };

// 리듀서
const reducer = createReducer(INITIAL_STATE, {
  [ADD]: (state, action) => state.friends.push(action.friend),
  [REMOVE]: (state, action) => state.friends.filer((friend) => friend.id !== action.friend.id),
  [EDIT]: (state, action) => {
    const index = state.friends.findIndex((friend) => friend.id === action.friend.id);
    if (index >= 0) {
      state.friend[index] = action.friend;
    
  },
});

export default reducer;

타임라인을 위한 리덕스 코드 작성

import createReducer from '../Common/createReducer';

// 액션 타입
const ADD = 'timeline/ADD';
const REMOVE = 'timeline/REMOVE';
const EDIT = 'timeline/EDIT';
const INCREASE_NEXT_PAGE = 'timeline/INCREASE_NEXT_PAGE';

// 액션 생성자 함수
export const addTimeline = (timeline) => ({ type: ADD, timeline });
export const removeTimeline = (timeline) => ({ type: REMOVE, timeline });
export const editTimeline = (timeline) => ({ type: EDIT, timeline });
export const increaseNextPage = () => ({ type: INCREASE_NEXT_PAGE });

const INITIAL_STATE = { timelines: [], nextPage: 0 };

const reducer = createReducer(INITIAL_STATE, {
  [ADD]: (state, action) => state.timelines.push(action.timeline),
  [REMOVE]: (state, action) =>
    (state.timelines = state.timelines.filer((timeline) => timeline.id !== action.timeline.id)),
  [EDIT]: (state, action) => {
    const index = state.timelines.findIndex((timeline) => timeline.id === action.timeline.id);
    if (index >= 0) {
      state.timelines[index] = action.timeline;
    }
  },
  [INCREASE_NEXT_PAGE]: (state, action) => (state.nextPage += 1),
});

export default reducer;

src/index.js에 추가

import { createStore, combineReducers } from 'redux';
import timelineReducer, {
  addTimeline,
  editTimeline,
  increaseNextPage,
  removeTimeline,
} from './timeline/state';
import friendReducer, { addFriend, editFriend, removeFriend } from './friend/state';

// reducer 합치기
const reducer = combineReducers({
  timeline: timelineReducer,
  friend: friendReducer,
});
// 스토어 생성
const store = createStore(reducer);
store.subscribe(() => {
  const state = store.getState();
  console.log(state);
});

store.dispatch(addTimeline({ id: 1, desc: '즐거워' }));
store.dispatch(addTimeline({ id: 2, desc: '리덕스' }));
store.dispatch(increaseNextPage());
store.dispatch(editTimeline({ id: 2, desc: '수정' }));
store.dispatch(removeTimeline({ id: 1 }));

store.dispatch(addFriend({ id: 1, name: 'kang' }));
store.dispatch(addFriend({ id: 2, name: 'kim' }));
store.dispatch(editFriend({ id: 2, name: 'son' }));
store.dispatch(removeFriend({ id: 1, name: 'kang' }));

0개의 댓글