17. 리덕스 사용해 리액트 어플리케이션 상태 관리

히치키치·2022년 1월 8일
0

React_Advance

목록 보기
7/9
post-thumbnail

✔ 작업 환경 설정 & UI 설정

1. 환경 설정

yarn create react-app react-redux
cd react-redux
yarn add redux react-redux

2. 사용할 패턴 설정


프로젠테이셔널 컴포넌트
: 상태 관리 X
: props 받아와 화면에 UI 보여주기만 함

컨테이너 컴포넌트
: 리덕스와 연동된 컴포넌트
: 리덕스로부터 상태 받아와 리덕스 스토어에 액션을 디스패치

3. 카운터 컴포넌트 생성

숫자 더하기/빼기

import React from "react";

const Counter = ({ number, onIncrease, onDecrease }) => {
  return (
    <div>
      <h1>{number}</h1>
      <div>
        <button onClick={onIncrease}>+1</button>
        <button onClick={onDecrease}>-1</button>
      </div>
    </div>
  );
};

export default Counter;

4. 할 일 목록 컴포넌트 생성

components/Todos.js

import React from "react";
const TodoItem = ({ todo, onToggle, onRemove }) => {
  return (
    <div>
      <input type="checkbox" />
      <span>예제 텍스트</span>
      <button>삭제</button>
    </div>
  );
};

const Todos = ({
  input, //인풋에 입력되는 텍스트
  todos, //할 일 목록이 들어 있는 객체
  onChangeInput,
  onInsert,
  onToggle,
  onRemove,
}) => {
  const onSubmit = (e) => {
    e.preventDefault();
  };
  return (
    <div>
      <form onSubmit={onSubmit}>
        <input />
        <button type="submit">등록</button>
      </form>
      <div>
        <TodoItem />
        <TodoItem />
        <TodoItem />
        <TodoItem />
        <TodoItem />
      </div>
    </div>
  );
};

export default Todos;

App.js

import React from "react";
import Counter from "./components/Counter";
import Todos from "./components/ToDos";

const App = () => {
  return (
    <div>
      <Counter number={0} />
      <hr />
      <Todos />
    </div>
  );
};

export default App;

✔ 리덕스 코드 작성

Ducks 패턴 사용
: 액션 타입, 액션 생성 함수, 리듀서 함수를 기능별로 파일 하나에 몰아 작성

counter 모듈 작성

1. 액션 타입 정의

  • 액션 타입 : 대문자
  • 문자열 : '모듈 이름/액션 이름'
    (모듈 이름 넣어 액션 이름끼리의 충돌 막음)

src/modules/counter.js

const INCREASE="counter/INCREASE";
const DECREASE="counter/DECREASE";

2. 액션 생성 함수 만들기/내보내기

  • export 키워드 사용해 다른 파일에서 해당 함수 불러와 사용 가능

src/modules/counter.js

(...)
 
export const increase=()=>({type:INCREASE});
export const decrease=()=>({type:DECREASE});

3. 초기 상태 및 리듀서 함수 생성
src/modules/counter.js

(...)
//초기 상태 설정
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;
  }
}

//리듀서 함수 내보내기
export default counter;

todos 모듈 작성

1. 액션 타입 정의

src/modules/todos.js

const CHANGE_INPUT="todos/CHANGE_INPUT"; //Input 값 변경
const INSERT="todos/INSERT"; //새로운 todo 등록
const TOGGLE="todos/TOGGLE"; //todo 체크/체크해제
const REMOVE="todos/REMOVE"; //todo 제거

2. 액션 생성 함수 작성

src/modules/todos.js

(...)
 export const changeInput=input=>({

    type:CHANGE_INPUT,
    input

});

let id=3; //초기 상태 작성 때 todo 객체 2개를 사전에 미리 넣을 예정임

export const insert=text=>({
    //호출될 때마다 id값에 1씩 더해줘야함으로 id 값은 todo객체가 들고 있게 될 고유값
    type:INSERT,
    todo:{
        id:id++,
        text,
        done:false
    }
});

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


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

});

3. 초기 상태 설정

src/modules/todos.js

//초기 상태 설정
const initialState={
    input:"",
    todos:[
        {
            id:1,
            text:"리덕스 기초 배우기",
            done:true
        },

        {
            id:2,
            text:"리액트와 리덕스 사용하기",
            done:true
        },

    ]
}

4. 리듀서 함수 만들기

src/modules/todos.js

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 state;
  }
}

export default todos;

5. 루트 리듀서 생성
리덕스에서 제공하는 combineReducers 함수 이용해 기존에 만든 여러 리듀스를 하나로 합쳐서 리듀서 한개만 사용하는 스토어에서 가능하도록 함

src/modules/index.js

import { combineReducer } from "redux";
import counter from "./counter";
import todos from "./todos";

const rootReducer = combineReducer({
  counter,
  todos,
});

export default rootReducer;

✔ 리액트 어플리케이션에 리덕스 적용

1. 스토어 생성

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"));

2. Provider 컴포넌트 사용해 리덕스 적용

리액트 컴포넌트에서 스토어를 사용할 수 있도록 App 컴포넌트를 react-redux에서 제공하는 Provider 컴포넌트로 감싸기

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";
import { Provider } from "react-redux";

const store = createStore(rootReducer);

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

3. Redux Devtools 설치 및 적용

크롬 확장 프로그램에서 redux DevTools 설치

//패키지 설치
yarn add redux-devtools-extension

프로젝트에 패키지 적용
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";
import { Provider } from "react-redux";
import { composeWithDevTools } from "redux-devtools-extension";

const store = createStore(rootReducer, composeWithDevTools());

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

개발자 도구 열어 Redux에서 State로 리덕스 스토어 내부 상태 확인

✔ 컨테이너 컴포넌트 생성

컨테이너 컴포넌트
: 리덕스 스토어와 연동된 컴포넌트
컴포넌트에서 리덕스 스토어에 접근해 원하는 상태 받아오기
액션도 디스패치 해주기

CounterContainer 생성

src/containers/CounterContainer.js

import React from "react";
import Counter from "../components/Counter";

const CounterContainer = () => {
  return <Counter />;
};

export default CounterContainer;

react-redux의 connect 함수 사용해 컴포넌트를 리덕스와 연동

connect(mapStateToProps,mapDispatchToProps)(연동할 컴포넌트)
  • mapStateToProps : 리덕스 스토어 안의 상태를 컴포넌트의 props로 넘겨주기 위해 설정하는 함수
  • mapDispatchToProps : 액션 생성 함수를 컴포넌트의 props로 넘겨주기 위해 사용하는 함수
  • connect 함수 호출 후 반환된 함수에 컴포넌트 파라미터로 넣으면 리덕스와 연동된 컴포넌트 만들어짐

src/containers/CounterContainer.js

import React from "react";
import { connect } from "react-redux";
import Counter from "../components/Counter";

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

const mapStateToProps = (state) => ({
  //state를 파라미터로 받아오며 해당 값은 현재 스토어가 지닌 상태
  number: state.counter.number,
});

const mapDispatchToProps = (dispatch) => ({
  //store의 내장 함수 dispatch를 파라미터로 받아옴

  //진행 절차 설명을 위한 임시 함수
  increase: () => {
    console.log("increase");
  },
  decrease: () => {
    console.log("decrease");
  },
});

//connect 함수 호출
export default connect(mapStateToProps, mapDispatchToProps)(CounterContainer);

App에서 CounterContainer로 교체

App.js

import React from "react";
import Todos from "./components/ToDos";
import CounterContainer from "./containers/CounterContainer";

const App = () => {
  return (
    <div>
      <CounterContainer />
      <hr />
      <Todos />
    </div>
  );
};

export default App;

브라우저에서 +1과 -1 클릭에 따른 콘솔에 increase와 decrease 찍힘

임시 함수 대신 액션 생성 함수 가져와 액션 객체 생성 후 디스패치

src/containers/CounterContainer.js


(...)
 import { increase, decrease } from "../modules/counter";


(...)
 const mapDispatchToProps = (dispatch) => ({
  //store의 내장 함수 dispatch를 파라미터로 받아옴

  increase: () => {
    dispatch(increase());
  },
  decrease: () => {
    dispatch(decrease());
  },
});

export default connect(mapStateToProps, mapDispatchToProps)(CounterContainer);

+1, -1 버튼 클릭에 따른 숫자 변화

connect 함수 내부에 익명 함수 형태로 선언 가능

(...)
 const CounterContainer = ({ number, increase, decrease }) => {
  return (
    <Counter number={number} onIncrease={increase} onDecrease={decrease} />
  );
};


export default connect(
  //connect 함수 내부 익명 함수로 작성
    state=>({
        number:state.counter.number,
    }),
    dispatch=>({
        increase:()=>dispatch(increase()),
        decrease=()=>dispatch(decrease())
    })

    )(CounterContainer);

리덕스 제공 bindActionCreators 함수 이용해 각 액션 생성 함수 호출하고 dispatch로 감싸는 작업 한 번에 수행 가능

(...)
 import { bindActionCreators } from "redux";

(...)
 export default connect(
  (state) => ({
    number: state.counter.number,
  }),
  (dispatch) =>
    bindActionCreators(
      {
        increase,
        decrease,
      },
      dispatch
    )
)(CounterContainer);
 

mapDispatchToProps의 파라미터를 액션 생성 함수로 이루어진 객체 형태로 넣어 connect 함수가 내부적으로 bindActionCreators 작업 수행

(...)
 export default connect(
  (state) => ({
    number: state.counter.number,
  }),
  {
    increase,
    decrease,
  }
)(CounterContainer);

TodosContainer 생성

todos 모듈에서 작성한 액션 생성 함수와 상태 안의 값을 컴포넌트의 props로 전달

src/containers/TodosContainer.js

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 분리
  //state.todos.input 대신 todos.input 사용
  ({ todos }) => ({
    input: todos.input,
    todos: todos.todos,
  }),
  {
    changeInput,
    insert,
    toggle,
    remove,
  }
)(TodosContainer);

App 컴포넌트에서 TodosContainer 컴포넌트 보이게 작성

import React from "react";
import CounterContainer from "./containers/CounterContainer";
import TodosContainer from "./containers/TodosContainer";

const App = () => {
  return (
    <div>
      <CounterContainer />
      <hr />
      <TodosContainer />
    </div>
  );
};

export default App;

Todos 컴포넌트에서 받아 온 props 사용하도록 구현

src/components/Todos.js

import React from "react";
const TodoItem = ({ todo, onToggle, onRemove }) => {
  return (
    <div>
      <input
        type="checkbox"
        onClick={() => onToggle(todo.id)}
        checked={todo.done}
        readOnly={true}
      />
      <span style={{ textDecoration: todo.done ? "line-through" : "none" }}>
        {todo.text}
      </span>
      <button onClick={() => onRemove(todo.id)}>삭제</button>
    </div>
  );
};

const Todos = ({
  input, //인풋에 입력되는 텍스트
  todos, //할 일 목록이 들어 있는 객체
  onChangeInput,
  onInsert,
  onToggle,
  onRemove,
}) => {
  const onSubmit = (e) => {
    e.preventDefault();
    onInsert(input);
    onChangeInput(""); //등록 후 인풋 초기화
  };
  const onChange = (e) => onChangeInput(e.target.value);
  return (
    <div>
      <form onSubmit={onSubmit}>
        <input value={input} onChange={onChange} />
        <button type="submit">등록</button>
      </form>
      <div>
        {todos.map((todo) => (
          <TodoItem
            todo={todo}
            key={todo.id}
            onToggle={onToggle}
            onRemove={onRemove}
          />
        ))}
      </div>
    </div>
  );
};

export default Todos;

✔ 더 편리한 리덕스 사용

1. redux-actions

//라이브러리 설치
yarn add redux-actions

counter 모듈에 적용

  • createAction : 액션 생성 함수 작성
  • handleActions : 리듀서 함수 작성
    첫번째 인자 - 각 액션에 대한 업데이트 함수
    두번째 인자 - 초기 상태

modules/counter.js

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

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

//createAction 사용해 액션 생성 함수 선언/내보내기
export const increase = createAction(INCREASE);
export const decrease = createAction(DECREASE);

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

//handleActions 사용해 리듀서 함수 작성 (가독성 높아짐)
const counter = handleActions(
  {
    //각 액션에 대한 업데이트 함수
    [INCREASE]: (state, action) => ({ number: state.number + 1 }),
    [DECREASE]: (state, action) => ({ number: state.number - 1 }),
  },
  //초기 상태 넣기
  initialState
);

//리듀서 함수 내보내기
export default counter;

todos 모듈에 적용

  • createAction :
    액션 선언
    payload 정의하는 함수 작성해 액션 생성 함수에 온 받아온 파라미터 변형 가능
//예시
const MY_ACTION="sample/MY_ACTION";
const myAction=createAction(MY_ACTION,text=>`${text}!`);
const action=myAction('hello world');

/*결과
{type : MY_ACTION, payload : 'hello world!' }*/
  • inserttodo 객체를 액션 객체 안에 넣어줘야함으로
    두번째 인자로 text로 받아 todo 객체가 반환되는 함수 작성
import { createAction, handleActions } from "redux-actions";

(...)

export const changeInput = createAction(CHANGE_INPUT, (input) => input);

let id = 3; //초기 상태 작성 때 todo 객체 2개를 사전에 미리 넣을 예정임

export const insert = createAction(INSERT, (text) => ({
  //insert가 호출될 때마다 1씩 더해짐
  id: id++,
  text,
  done: false,
}));

export const toggle = createAction(TOGGLE, (id) => id);
export const remove = createAction(REMOVE, (id) => id);

(...)
  • createAction으로 만든 액션 생성 함수는 인자로 받은 값을 객체 안으로 넣을 때 action.payload로 공통적으로 넣음
    -> action.payload 값 조회해 업데이트하도록 구현
import { createAction, handleActions } from "redux-actions";

(....)

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;
  • 객체 비구조화 할당 문법으로 action 값의 payload에 이름 새롭게 설정해 action.payload가 의미하는 바 쉽게 파악 가능
(...)
 
 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;

2. immer

todos 모듈에 적용
간단한 counter 모듈에 적용시 더 복잡해짐

//라이브러리 설치
yarn add immer
import { createAction, handleActions } from "redux-actions";
import produce from "immer";

(....)

const todos = handleActions(
  {
    [CHANGE_INPUT]: (state, { payload: input }) =>
      produce(state, (draft) => {
        draft.input = input;
      }),
    [INSERT]: (state, { payload: todo }) =>
      produce(state, (draft) => {
        draft.todos.push(todo);
      }),
    [TOGGLE]: (state, { payload: id }) =>
      produce(state, (draft) => {
        const todo = draft.todos.find((todo) => todo.id === id);
        todo.done = !todo.done;
      }),
    [REMOVE]: (state, { payload: id }) =>
      produce(state, (draft) => {
        const index = draft.todos.findIndex((todo) => todo.id === id);
        draft.todos.splice(index, 1);
      }),
  },
  initialState
);

export default todos;

✔ Hooks 사용해 컨테이너 컴포넌트 생성

1. useSelector 상태 조회

//사용법
const 결과 = useSelector(상태 선택 함수);
  • mapStateToProps와 형태 동일한 상태 선택 함수
  • connect 대신 useSelect 사용해 counter.number 조회하고 props로 전달

src/container/CounterContainer.js

//useSelector 불러오기
import { useSelector } from "react-redux";
(...)
 
//useSelector 사용해 counter.number 조회
const CounterContainer = () => {
  const number = useSelector((state) => state.counter.number);
  return <Counter number={number} />;
};

export default CounterContainer;

2. useDispatch 액션 디스패치

//사용법
const dispatch = useDispatch();
dispatch({type:"SAMPLE_ACTION"})
  • 숫자 바뀜에 따라 컴포넌트가 리렌더링될 때마다 onIncrease와 onDecrease 새롭게 작성됨 -> 최적화 필요

src/container/CounterContainer.js

import React from "react";
import { useSelector, useDispatch } 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 사용해 액션을 dispatch 하는 함수 감싸 주기
import React, { useCallback } from "react";
import { useSelector, useDispatch } 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={onIncrease} onDecrease={onDecrease} />
  );
};

export default CounterContainer;

3. useStore 리덕스 스토어 사용

  • useStore 사용해 컴포넌트 내부에서 리덕스 스토어 객체 직접 사용 가능
    (정말 어쩔 수 없을 때만...사용하기...)
//사용법
const store=useStore();
store.dispatch({type: "SAMPLE_ACTION"});
store.getState();
         

4. TodosContainer를 Hooks 전환

TodosContainer를 useSelector와 useDispatch 사용해 다시 작성해보기

  • useSelector 사용해 비구조화 할당 문법
  • useDispatch 사용 시 어떤 값이 액션 생성 함수의 인자로 사용되는지 모두 명시해줘야 함 -> 번거로움
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 }) => ({
    //useSelector 사용해 비구조화 할당 문법
    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 TodosContainer;

5. useActions 유틸 Hooks 만들어 사용

  • 액션 생성 함수를 사용해 액션 객체 만들고, 스토어에 디스패치하는 작업을 해주는 작업 자동으로 하는 hooks 작성
  • 첫번째 인자 : 액션 생성 함수로 이루어진 배열
  • 두번째 인자 : deps 배열로 해당 배열의 원소 값 바뀌면 액션을 디스패치하는 함수 새롭게 만듦

lib/useAction.js

import { bindActionCreators } from "redux";
import { useDispatch } from "react-redux";
import { useMemo } from "react";

export default function useActions(actions, deps) {
  const dispatch = useDispatch();
  return useMemo(
    () => {
      if (Array.isArray(actions)) {
        return actions.map((a) => bindActionCreators(a, dispatch));
      }
      return bindActionCreators(actions, dispatch);
    },
    deps ? [dispatch, ...deps] : deps
  );
}

TodoContainer에서 useActions 불러와 사용

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

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

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

export default TodosContainer;

6. connect 함수와 useSelector/useDispatch 차이점

  • connect 사용해 컨테이너 컴포넌트 만드는 경우
    해당 컨테이너 컴포넌트의 부모 컴포넌트가 리랜더링될 때 해당 컨테이너 컴포넌트의 props가 바뀌지 않으면 리랜더링 방지되며 최적화

  • useSelector 사용해 리덕스 상태 조회하는 경우
    자동으로 최적화 작업 이뤄지지 않음
    React.memo를 컨테이너 컴포넌트에 사용해 줘야함

//예시
export default React.memo(CounterContainer);

0개의 댓글