[WEEK 15] 리덕스.. 난 그것이 정말 알고 싶다 - 1

신호정 벨로그·2021년 11월 16일
0

Today I Learned

목록 보기
79/89

Redux란?

리덕스는 리액트의 상태 관리 라이브러리로 전역 상태를 관리하고자 할 때 효과적이다.

리덕스를 사용하면 컴포넌트의 상태 업데이트 관련 로직을 다른 파일로 분리시켜 관리할 수 있다.

또한 컴포넌트끼리 상태를 공유해야 하는 경우 여러 컴포넌트를 거치지 않고 상태 값을 전달하거나 업데이트할 수 있다.

1. Redux 개념

1.0 useReducer

useReducer는 useState보다 더 다양한 컴포넌트 상황에 따라 다양한 상태를 다른 값으로 업데이트하고자 할 때 사용하는 Hook이다.

리듀서현재 상태, 그리고 업데이트를 위해 필요한 정보를 담은 액션(action) 값을 전달받아 새로운 상태를 반환하는 함수이다.

useReducer의 첫 번째 파라미터에는 리듀서 함수를 입력하고, 두 번째 파라미터에는 해당 리듀서의 기본값을 입력한다.

useReducer를 사용하면 state 값과 dispatch 함수를 받는다.

state현재 가리키고 있는 상태고, dispatch액션을 발생시키는 함수다.

dispatch(action)과 같은 형태로, 함수 안에 파라미터로 액션 값을 입력하면 리듀서 함수가 호출되는 구조다.

useReducer의 가장 큰 장점은 컴포넌트 업데이트 로직을 컴포넌트 밖으로 꺼낼 수 있다는 것이다.

function reducer(state, action) {
	return { ... };
}
import React, { useReducer } from "react";

function reducer(state, action) {
  switch (action.type) {
    case "INCREMENT":
      return { value: state.value + 1 };
    case "DECREMENT":
      return { value: state.value - 1 };
    default:
      return state;
  }
}

const Counter = () => {
  const [state, dispatch] = useReducer(reducer, { value: 0 });

  return (
    <div>
      <p>A current value of Counter: {state.value}</p>
      <button onClick={() => dispatch({ type: "INCREMENT" })}> + 1</button>
      <button onClick={() => dispatch({ type: "DECREMENT" })}> - 1</button>
    </div>
  );
};

export default Counter;

1.1 액션

상태의 변화가 필요하면 액션(action)이 발생한다.

액션 객체는 {type: 'TOGGLE_VALUE'}와 같이 type 필드를 반드시 가진다.

type 필드의 값을 액션의 이름이라고 생각할 수 있다.

1.2 액션 생성 함수

액션 생성 함수(action creator)는 액션 객체를 만들어 주는 함수다.

function addTodo(data) {
  return {
    type: 'ADD_TODO',
    data
  };
}

const changeInput = text => ({
  type: 'CHANGE_INPUT',
  text

1.3 리듀서

리듀서(reducer)는 변화를 일으키는 함수다. 액션을 만들어서 발생시키면 리듀서가 현재 상태와 전달받은 액션 객체를 파라미터로 입력 받아 새로운 상태를 만들어 반환한다.

const initialState = {
  counter: 1
};

function reducer(state = initialState, action) {
  switch (action.type) {
    case INCREMENT:
      return {
        counter: state.counter + 1
      };
    default:
      	return state;
  }
}

1.4 스토어

프로젝트에 리덕스를 적용하기 위해 스토어(store)를 생성한다. 한 개의 프로젝트는 단 하나의 스토어만 가질 수 있다. 스토어 안에는 현재 애플리케이션 상태와 리듀서를 보관한다.

1.5 디스패치

디스패치(dispatch)'액션을 발생시키는' 스토어의 내장 함수다.

dispatch(action)의 형태로 액션 객체를 파라미터로 입력하여 호출한다.

디스패치 함수가 호출되면 스토어는 리듀서 함수를 실행시켜 새로운 상태로 변경한다.

2. Redux 연습

index.js

import { Provider } from "react-redux";
import { createStore } from "redux";

const something = 100;

function reducer(state = something, action) {
	return state;
}

let store = createStore(reducer);

ReactDOM.render(
	<React.StrictMode>
    	<Provider store={store}>
        	<App />
        </Provider>
    </React.StrictMode>,
    document.getElementById("root")
);

App.js

import { useSelector } from "react-redux";
import "./App.css";

function App() {
	const 

3. Redux 응용

리액트 애플리케이션에서 리덕스를 사용하면, 상태 업데이트에 관한 로직을 모듈로 따로 분리하여 컴포넌트 파일과 별개로 관리할 수 있다.

리액트 애플리케이션에서 리덕스를 사용할 때는 store 인스턴스를 직접 사용하기보다는 주로 react-redux라는 라이브러리에서 제공하는 유틸 함수(connect)와 컴포넌트(Provider)를 사용하여 리덕스 관련 작업을 처리한다.

리액트 프로젝트에서 리덕스를 사용할 때 가장 많이 사용하는 패턴은 프레젠테이셔널 컴포넌트와 컨테이너 컴포넌트를 분리하는 것이다.

프레젠테이셔널 컴포넌트는 주로 상태 관리가 이루어지지 않고, props를 받아와서 화면에 UI를 보여주는 컴포넌트를 의미한다.

컨테이너 컴포넌트는 리덕스와 연동되어 있는 컴포넌트로 리덕스로부터 상태를 받아오기도 하고 리덕스 스토어에 액션을 디스패치하기도 한다.

UI에 관련된 프레젠테이셔널 컴포넌트는 src/components 경로에 저장하고, 리덕스와 연동된 컨테이너 컴포넌트는 src/containers 컴포넌트에 작성한다.

가장 일반적인 구조로 actions, constants, reducers라는 세 개의 디렉터리를 만들고 그 안에 기능별로 파일을 만드는 방식이다.

Ducks 패턴은 액션 타입, 액션 생성 함수, 리듀서 함수를 기능별로 파일 하나에 모두 작성하는 방식이다.

Ducks 패턴을 사용하여 액션 타입, 액션 생성 함수, 리듀서를 작성한 코드를 '모듈'이라고 한다.

modules 디렉터리를 생성하고 액션 타입을 정의한다.

액션 타입은 대문자로 정의하고, 문자열 내용은 '모듈 이름/액션 이름'과 같은 형태로 작성한다.

액션 타입을 정의한 다음에는 액션 생성 함수를 정의한다. 액션 생성 함수에 파라미터 필요한 경우 전달받은 파라미터는 액션 객체 안에 추가 필드로 들어간다.

모듈의 초기 상태리듀서 함수를 정의한다.

export는 여러 개를 내보낼 수 있지만 export default는 한 개만 내보낼 수 있다.

// 액션 타입 정의
const CHANGE_INPUT = "todos/CHANGE_INPUT";
const INSERT = "todos/INSERT";
const TOGGLE = "todos/TOGGLE";
const REMOVE = "todos/REMOVE";

// 액션 생성 함수 정의
export const changeInput = (input) => ({
  type: CHANGE_INPUT,
  input,
});

export const insert = (text) => ({
  type: INSERT,
  todo: {
    id: id++,
    text,
    done: false,
  },
});

let id = 3;

export const toggle = (id) => ({
  type: TOGGLE,
  id,
});

export const remove = (id) => ({
  type: REMOVE,
  id,
});

// 모듈의 초기 상태 설정
const initialState = {
  input: "",
  todos: [
    {
      id: 1,
      text: "리덕스 기초 배우기",
      done: true,
    },
    {
      id: 2,
      text: "리액트와 리덕스 사용하기",
      done: false,
    },
  ],
};

// 리듀서 함수 정의
function todos(state = initialState, action) {
  switch (action.type) {
    case CHANGE_INPUT:
      return {
        ...state,
        input: action.input,
      };
    case INSERT:
      return {
        ...state,
        todos: state.todos.concat(action.todo),
      };
    case TOGGLE:
      return {
        ...state,
        todos: state.todos.map((todo) =>
          todo.id === action.id ? { ...todo, done: !todo.done } : todo
        ),
      };
    case REMOVE:
      return {
        ...state,
        todos: state.todos.filter((todo) => todo.id !== action.id),
      };
    default:
      return false;
  }
}

export default todos;

createStore 함수를 사용하여 스토어를 만들 때는 리듀서를 하나만 사용해야 한다.

combineReducers라는 유틸 함수를 사용하여 기존에 만들었던 리듀서를 하나로 합쳐야 한다.

스토어를 만들고 리액트 애플리케이션에 리덕스를 적용하는 작업은 src 디렉터리의 index.js에서 이루어진다.

import React from "react";
import ReactDOM from "react-dom";
import { createStore } from "redux";
import "./index.css";
import App from "./App";
import rootReducer from "./modules";

// 스토어 생성
const store = createStore(rootReducer);

ReactDOM.render(<App />, document.getElementById("root"));

리액트 컴포넌트에서 스토어를 사용할 수 있도록 App 컴포넌트를 Provider 컴포넌트로 감싼다. Provider 컴포넌트를 사용하기 위해 store를 props로 전달해야 한다.

import React from "react";
import ReactDOM from "react-dom";
import { createStore } from "redux";
import { Provider } from "react-redux";
import "./index.css";
import App from "./App";
import rootReducer from "./modules";

// 스토어 생성
const store = createStore(rootReducer);

ReactDOM.render(
  // Provider 컴포넌트 생성
  <Provider store={store}>
    <App />
  </Provider>,
  document.getElementById("root")
);

컴포넌트를 리덕스와 연동하려면 react-redux에서 제공하는 connect 함수를 사용해야 한다.

connect(mapStateToProps, mapDispatchToProps)(연동할 컴포넌트)

mapStateToProps는 리덕스 스토어 안의 상태를 컴포넌트의 props로 넘겨주기 위해 설정하는 함수이고, mapDispatchToProps는 액션 생성 함수를 컴포넌트의 props로 넘겨주기 위해 사용하는 함수이다.

connect 함수를 호출하면 또 다른 함수를 반환한다. 반환된 함수에 컴포넌트를 파라미터로 입력하면 리덕스와 연동된 컴포넌트가 생성된다.

const makeContainer = connect(mapStateToProps, mapDispatchProps)
makeContainer(타깃 컴포넌트)

mapStateToProps와 mapDispatchProps에서 반환하는 객체 내부의 값들은 컴포넌트의 props로 전달된다.

mapStateToPropsstate를 파라미터로 입력 받으며, 이 값은 현재 스토어가 지니고 있는 상태를 가리킨다.

mapDispatchToProps는 store의 내장 함수 dispatch를 파라미터로 입력 받는다.

mapDispatchToProps에 해당하는 파라미터를 함수 형태가 아닌 액션 생성 함수로 이루어진 객체 형태로 입력하는 방법이 있다.

두 번째 파라미터를 객체 형태로 입력하면 connect 함수가 내부적으로 bindActionCreators 작업을 대신한다.

import React from "react";
import { connect } from "react-redux";
import { changeInput, insert, toggle, remove } from "../modules/todos";
import Todos from "../components/Todos";

const TodosContainer = ({
  input,
  todos,
  changeInput,
  insert,
  toggle,
  remove,
}) => {
  return (
    <Todos
      input={input}
      todos={todos}
      onChangeInput={changeInput}
      onInsert={insert}
      onToggle={toggle}
      onRemove={remove}
    />
  );
};

export default connect(
  ({ todos }) => ({
    // 비구조화 할당을 통해 todos를 분리하여 state.todos.input 대신 todos.input을 사용
    input: todos.input,
    todos: todos.todos,
  }),
  {
    changeInput,
    insert,
    toggle,
    remove,
  }
)(TodosContainer);

redux-actions를 사용하면 액션 생성 함수를 효과적으로 작성할 수 있다. createActions를 사용하면 매번 객체를 직접 만들 필요 없이 간단하게 선언할 수 있다.

또한 리듀서 함수를 정의할 때도 switch/case 문이 아닌 handleActions 함수를 사용하면 각 액션마다 업데이트 함수를 설정하는 형식으로 작성할 수 있다.

import { createActions } from "redux-actions";

// 액션 타입 정의
const INCREASE = "counter/INCREASE";
const DECREASE = "counter/DECREASE";

// // 액션  생성 함수 정의
// export const increase = () => ({ type: INCREASE });
// export const decrease = () => ({ type: DECREASE });

// createActions를 사용하여 액션 생성 함수 정의
export const increase = createActions(INCREASE);
export const decrease = createActions(DECREASE);

// 모듈의 초기 상태 설정
const initialState = {
  number: 0,
};

// // 리듀서 함수 정의
// function counter(state = initialState, action) {
//   switch (action.type) {
//     case INCREASE:
//       return {
//         number: state.number + 1,
//       };
//     case DECREASE:
//       return {
//         number: state.number - 1,
//       };
//     default:
//       return state;
//   }
// }

// handleActions를 사용하여 리듀서 함수 정의
const counter = handleActions(
  {
    [INCREASE]: (state, action) => ({ number: state.number + 1 }),
    [DECREASE]: (state, action) => ({ number: state.number - 1 }),
  },
  initialState
);

export default counter;
import { createActions, handleActions } from "redux-actions";

// 액션 타입 정의
const CHANGE_INPUT = "todos/CHANGE_INPUT";
const INSERT = "todos/INSERT";
const TOGGLE = "todos/TOGGLE";
const REMOVE = "todos/REMOVE";

// // 액션 생성 함수 정의
// export const changeInput = (input) => ({
//   type: CHANGE_INPUT,
//   input,
// });

// createActions를 사용하여 액션 생성 함수 정의
export const changeInput = createActions(CHANGE_INPUT, (input) => input);
export const insert = createActions(INSERT, (text) => ({
  id: id++,
  text,
  done: false,
}));
export const toggle = createActions(TOGGLE, (id) => id);
export const remove = createActions(REMOVE, (id) => id);

export const insert = (text) => ({
  type: INSERT,
  todo: {
    id: id++,
    text,
    done: false,
  },
});

let id = 3;

export const toggle = (id) => ({
  type: TOGGLE,
  id,
});

export const remove = (id) => ({
  type: REMOVE,
  id,
});

// 모듈의 초기 상태 설정
const initialState = {
  input: "",
  todos: [
    {
      id: 1,
      text: "리덕스 기초 배우기",
      done: true,
    },
    {
      id: 2,
      text: "리액트와 리덕스 사용하기",
      done: false,
    },
  ],
};

// // 리듀서 함수 정의
// function todos(state = initialState, action) {
//   switch (action.type) {
//     case CHANGE_INPUT:
//       return {
//         ...state,
//         input: action.input,
//       };
//     case INSERT:
//       return {
//         ...state,
//         todos: state.todos.concat(action.todo),
//       };
//     case TOGGLE:
//       return {
//         ...state,
//         todos: state.todos.map((todo) =>
//           todo.id === action.id ? { ...todo, done: !todo.done } : todo
//         ),
//       };
//     case REMOVE:
//       return {
//         ...state,
//         todos: state.todos.filter((todo) => todo.id !== action.id),
//       };
//     default:
//       return false;
//   }
// }

// handleActions를 사용하여 리듀서 함수 정의
const todos = handleActions(
  {
    [CHANGE_INPUT]: (state, action) => ({
      ...state,
      input: action.payload,
    }),
    [INSERT]: (state, action) => ({
      ...state,
      todos: state.todos.concat(action.payload),
    }),
    [TOGGLE]: (state, action) => ({
      ...state,
      todos: state.todos.map((todo) =>
        todo.id === action.payload ? { ...todo, done: !todo.done } : todo
      ),
    }),
    [REMOVE]: (state, action) => ({
      ...state,
      todos: state.todos.filter((todo) => todo.id !== action.payload),
    }),
  },
  initialState
);

export default todos;

모듈 디렉터리 생성 -> 액션 타입 정의 -> 액션 생성 함수 정의 (createActions) -> 모듈 초기 상태 설정 -> 리듀서 함수 정의 (handleActions)

액션 생성 함수에 파라미터를 입력하는 경우 createActions로 액션을 생성하면 액션에 필요한 추가 데이터는 payload라는 이름을 사용한다.

액션 생성 함수에서 입력한 파라미터를 그대로 payload에 넣는 것이 아니라 createActions의 두 번째 함수에 payload를 정의하는 함수를 선언해서 넣는다.

createActions로 만든 액션 생성 함수는 파라미터로 받아 온 값을 객체 안에 넣을 때 action.id, action.todo와 같이 action.payload라는 이름을 공통적으로 사용한다.

기존의 업데이트 로직에서도 모두 action.payload 값을 조회하여 업데이트하도록 구현해야 한다.

액션 생성 함수는 액션에 필요한 추가 데이터를 모두 payload라는 이름을 사용하기 때문에 action.id, action.todo를 조회하는 대신, 공통적으로 action.payload 값을 조회하도록 리듀서를 구현해야 한다.

객체 비구조화 할당 문법으로 action 값의 payload 이름을 설정해주면 action.payload가 어떤 값을 의미하는지 파악할 수 있다.

import { createActions, handleActions } from "redux-actions";

// 액션 타입 정의
const CHANGE_INPUT = "todos/CHANGE_INPUT";
const INSERT = "todos/INSERT";
const TOGGLE = "todos/TOGGLE";
const REMOVE = "todos/REMOVE";

// createActions를 사용하여 액션 생성 함수 정의
export const changeInput = createActions(CHANGE_INPUT, (input) => input);
export const insert = createActions(INSERT, (text) => ({
  id: id++,
  text,
  done: false,
}));
export const toggle = createActions(TOGGLE, (id) => id);
export const remove = createActions(REMOVE, (id) => id);

export const insert = (text) => ({
  type: INSERT,
  todo: {
    id: id++,
    text,
    done: false,
  },
});

let id = 3;

export const toggle = (id) => ({
  type: TOGGLE,
  id,
});

export const remove = (id) => ({
  type: REMOVE,
  id,
});

// 모듈의 초기 상태 설정
const initialState = {
  input: "",
  todos: [
    {
      id: 1,
      text: "리덕스 기초 배우기",
      done: true,
    },
    {
      id: 2,
      text: "리액트와 리덕스 사용하기",
      done: false,
    },
  ],
};

// handleActions를 사용하여 리듀서 함수 정의
const todos = handleActions(
  {
    [CHANGE_INPUT]: (state, { payload: input }) => ({
      ...state,
      input,
    }),
    [INSERT]: (state, { payload: todo }) => ({
      ...state,
      todos: state.todos.concat(todo),
    }),
    [TOGGLE]: (state, { payload: id }) => ({
      ...state,
      todos: state.todos.map((todo) =>
        todo.id === id ? { ...todo, done: !todo.done } : todo
      ),
    }),
    [REMOVE]: (state, { payload: id }) => ({
      ...state,
      todos: state.todos.filter((todo) => todo.id !== id),
    }),
  },
  initialState
);

export default todos;

3.1 useSelector

리덕스 스토어와 연동된 컨테이너 컴포넌트를 만들 때 connect 함수를 사용하는 대신 react-redux에서 제공하는 Hooks를 사용할 수 있다.

useSelector 훅을 사용하면 connect 함수를 사용하지 않고도 리덕스의 상태를 조회할 수 있다.

const 결과 = useSelector(상태 선택 함수);

컨테이너 컴포넌트에 connect 함수 대신 useSelector를 사용하여 상태 값을 조회함으로써 컴포넌트에 props를 넘겨준다.

import React from "react";
import { useSelector } from "react-redux";
import Counter from "../components/Counter";
import { increase, decrease } from "../modules/counter";

const CounterContainer = () => {
  const number = useSelector((state) => state.counter.number);
  return <Counter number={number} />;
};

export default CounterContainer;

3.2 useDispatch

useDispatch 훅을 사용하면 컴포넌트 내부에서 스토어의 내장 함수 dispatch를 사용할 수 있다.

컨테이너 컴포넌트에서 액션을 디스패치해야 하면 useDispatch를 사용한다.

import React from "react";
import { useSelector } from "react-redux";
import Counter from "../components/Counter";
import { increase, decrease } from "../modules/counter";

const CounterContainer = () => {
  const number = useSelector((state) => state.counter.number);
  const dispatch = useDispatch();

  return (
    <Counter
      number={number}
      onIncrease={() => dispatch(increase())}
      onDecrease={() => dispatch(decrease())}
    />
  );
};

export default CounterContainer;

컴포넌트의 성능을 최적화해야 하는 경우 useCallback으로 액션을 디스패치하는 함수를 감싸주는 것이 좋다.

import React, { useCallback } from "react";
import { useSelector } from "react-redux";
import Counter from "../components/Counter";
import { increase, decrease } from "../modules/counter";

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={() => dispatch(increase())}
      onDecrease={() => dispatch(decrease())}
    />
  );
};

export default CounterContainer;

3.3 컨테이너 컴포넌트를 훅으로 전환하기

import React, { useCallback } from "react";
import { useSelector, useDispatch } from "react-redux";
import { changeInput, insert, toggle, remove } from "../modules/todos";
import Todos from "../components/Todos";

const TodosContainer = () => {
  const { input, todos } = useSelector(({ todos }) => ({
    input: todos.input,
    todos: todos.todos,
  }));
  const dispatch = useDispatch();
  const onChangeInput = useCallback(
    (input) => dispatch(changeInput(input)),
    [dispatch]
  );
  const onInsert = useCallback((text) => dispatch(insert(text)), [dispatch]);
  const onToggle = useCallback((id) => dispatch(toggle(id)), [dispatch]);
  const onRemove = useCallback((id) => dispatch(remove(id)), [dispatch]);

  return (
    <Todos
      input={input}
      todos={todos}
      onChangeInput={onChangeInput}
      onInsert={onInsert}
      onToggle={onToggle}
      onRemove={onRemove}
    />
  );
};

export default React.memo(TodosContainer);

useSelector를 활용한 비구조화 할당 문법과 useDispatch를 활용하여 각 액션을 디스패치하는 함수를 생성한다.

connect 함수를 사용하여 컨테이너 컴포넌트를 만들 경우, 해당 컨테이너 컴포넌트의 부모 컴포넌트가 리렌더링될 때 해당 컨테이너 컴포넌트의 props가 바뀌지 않았다면 리렌더링이 자동으로 방지되어 성능이 최적화된다.

반면 useSelector를 사용하여 리덕스 상태를 조회할 경우 최적화 작업이 자동으로 이루어지지 않으므로, 성능 최적화를 위해 React.memo를 컨테이너 컴포넌트에 사용해 주어야 한다.

0개의 댓글