React.js - redux2(리덕스 사용법)

Gyu·2022년 7월 28일
0

React.js

목록 보기
15/20
post-thumbnail

리덕스 사용 패턴

컴포넌트 구조

  • 리덕스를 사용할 때 가장 많이 사용하는 패턴은 프레젠테이셔널 컴포넌트와 컨테이너 컴포넌트를 분리하는 것이다.
    • 프레젠테이셔널 컴포넌트 : 상태관리가 이루어지지 않고, 그저 props를 전달받아 화면에 UI를 보여주기만 하는 컴포넌트
    • 컨테이너 컴포넌트 : 리덕스와 연동되어 있는 컴포넌트로, 리덕스로부터 상태를 받아오기도 하고 리덕스 스토어에 액션을 디스패치라기도 한다.
  • 이 패턴을 사용할 경우 코드의 재사용성이 높아지고, 관심사의 분리가 이루어져 UI를 작성할 때 좀 더 집중 할 수 있다.

폴더

  • 일반적인 구조 : actions, constants, reducers라는 세 개의 디렉토리를 만들고 그 안에 기능별로 파일을 하나씩 만드는 방법. 코드를 종류에 따라 다른 파일에 작성하여 정리할 수 있어 편리하지만, 새로운 액션을 만들 때마가 세 종류의 파일을 모두 수정해야하는 불편함이 있다. 예전 리덕스 공식문서에서도 사용되므로 가장 기본적인 구조.(현재 리덕스 공식 문서에서는 redux tool kit 사용 권장)
  • ducks 방법 : 하나의 파일에 액션 타입, 액션 생성함수, 리듀서 함수를 기능별로 작성하는 방식.

리액트 애플리케이션에 리덕스 적용하기

루트 리듀서 만들기

  • 스토어를 만들 때는 하나의 리듀서만 사용해야한다. 때문에 리덕스에서 제공하는 combineReducers() 함수를 이용해 여러 리듀서를 하나로 합쳐주어야한다.
  • modules/index.js
    import { combineReducers } from "redux"; // import combineReducers
    import counter from "./Counter"; // import reducers
    import todos from "./Todos";
    
    // combineReducers 함수의 매개변수에 import한 reducer를 넣어준다.
    const rootReducer = combineReducers({
        counter,
        todos
    })
    
    export default rootReducer;
    
    // 파일 이름을 index.js로 설정하면, 
    // import 시 디렉터리 이름까지만 입력하여 import 할 수 있다.
    // 예) import rootReducer from './modules';  

스토어 만들기

// src/index.js
import React from 'react';
import ReactDOM from 'react-dom';
import { createStore } from 'redux'; // import createStore function
import './index.css';
import App from './App';
import rootReducer from './modules'; // import toorReducer

// createStore 함수에 매개변수로 rootReducer를 전달하여 store 생성
const store = createStore(rootReducer); 

ReactDOM.render(
  <React.StrictMode>
    <App />
  </React.StrictMode>,
  document.getElementById('root')
);

Provider 컴포넌트를 사용하여 프로젝트에 리덕스 적용하기

  • 리액트 컴포넌트에서 스토어를 사용할 수 있도록 App 컴포넌트를 react-redux에서 제공하는 Provider 컴포넌트로 감싸주어야한다. Provider 컴포넌트를 사용할 때는 storeprops로 전달해 주어야한다.
import React from 'react';
import ReactDOM from 'react-dom';
import { createStore } from 'redux';
import { Provider } from 'react-redux'; // import Provider component
import './index.css';
import App from './App';
import rootReducer from './modules';

const store = createStore(rootReducer);

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

Redux DevTools 설치 및 적용

  • Redux DevTools는 리덕스 개발자 도구로 크롬 확장 프로그램을 설치하여 사용할 수 있다.
  • redux-devtools-extension을 설치한다.
    • npm i redux-devtools-extension
  • 리덕스 스토어를 만드는 과정에서 아래와 같이 적용한다.
    import React from 'react';
    import ReactDOM from 'react-dom';
    import { createStore } from 'redux';
    import { Provider } from 'react-redux';
    import { composeWithDevTools } from 'redux-devtools-extension'; // import
    import './index.css';
    import App from './App';
    import rootReducer from './modules';
    
    const store = createStore(rootReducer, composeWithDevTools()); // 적용
    
    ReactDOM.render(
      <Provider store={store}>
        <App />
      </Provider>,
      document.getElementById('root')
    );

컨테이너 컴포넌트 만들기

  • 컴포넌트와 리덕스를 연동하려면 react-redux에서 제공하는 connect 함수를 사용해야한다.
  • connect(mapStateToProps, mapDispatchToProps)(연동할 컴포넌트)
    • mapStateToProps : 리덕스 스토어 안의 상태를 컴포넌트의 props로 넘겨주기 위해 설정하는 함수

    • mapDispatchToProps: 액션 생성함수를 컴포넌트의 props로 넘겨주기 위해 사용하는 함수

    • connect 함수는 또 다른 함수를 반환하는데, 이 반환된 함수에 컴포넌트를 파라미터로 넣어주면 리덕스와 연동된 컴포넌트가 만들어진다. 위코드를 쉽게풀면 아래와 같다.

      const makeContainer = connect(mapStateToProps, mapDispatchToProps);
      
      makeContainer(연동할 컴포넌트);
  • mapStateToPropsmapDispatchToProps 에서 반환하는 객체 내부의 값들은 컴포넌트의 props로 전달된다.
  • mapStateToProps는 state를 파라미터로 받아오며, 이 값은 현재 스토어가 갖고 있는 상태값을 가리킨다.
  • mapDispatchToProps의 경우 store의 내장함수 dispatch를 파리미터로 받아온다.
  • import React from 'react';
    import { connect } from 'react-redux';
    import Counter from '../components/Counter';
    import { increase, decrease } from '../modules/Counter';
    
    // mapStateToProps, mapDispatchToProps가 반환하는 값이
    // CounterContainer의 props 자동 전달된다.(in connect 함수)
    const CounterContainer = ({number, increase, decrease}) => {
        return (
            <Counter 
    					number={number} 
    					onIncrease={increase} 
    					onDecrease={decrease} 
    				/>
        );
    };
    
    // 스토어의 state를 파라미터로 받아온다.
    const mapStateToProps = state => ({
        number: state.counter.number
    })
    
    // 스토어의 dispatch 함수를 파라미터로 받아온다.
    const mapDispatchToProps = dispatch => ({
        increase: () => {
    				// 액션 생성함수를 import하여 사용
            dispatch(increase());
        },
        decrease: () => {
            dispatch(decrease());
        }
    })
    
    // connect 함수로 컴포넌트와 리덕스 연결 => 컨테이너 컴포넌트 생성
    export default connect(
    	mapStateToProps, 
    	mapDispatchToProps
    )(CounterContainer);

코드 개선

  • connect 함수를 사용할 때 일반적으로 위 코드와 같이 mapStateToPropsmapDispatchToProps 를 미리 선언하고 사용한다. 하지만 connect 내부에 익명 함수 형태로 선언하면 코드가 조금 더 깔끔해진다.
    ...
    
    export default connect(
    	state => ({
    		number: state.counter.number
    	}),
    	dispatch => ({
    		increase: () => dispatch(increase()),
    		decrease: () => dispatch(decrease())
    	})
    )(CounterContainer);

bindActionCreators

  • 컨테이너 컴포넌트에서 액션을 디스패치하기 위해 각 액션 생성 함수를 호출하고 dispatch로 감싸는 작업은 번거롭다. 리덕스에서 제공하는 bindActionCreators함수를 사용하면 간편하게 액션을 디스패치 할 수 있다.
    ...
    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);

리덕스 더 편하게 사용하기

redux-actions

  • redux-actions을 사용하면 액션 생성함수를 더 짧은 코드로 작성할 수 있다. 또한 리듀서 작성 시 switch 문이 아니라 redux-actions에서 재공하는 함수를 이용해 더 쉽게 리듀서를 작성할 수 있다.
  • npm i redux-actions 로 설치
  • createAction(actionType[, payloadFunction])
    • 액션타입과 payload(액션에 필요한 추가 데이터)를 반환하는 함수를 전달받아 액션객체를 반환하는 함수

      // import createAction
      import { createAction } from 'redux-actions'
      
      // 액션 타입 정의
      const MY_ACTION = 'sample/MY_ACTION';
      
      // createAction함수로 액션 생성 함수 만들기
      const myAction = createAction(MY_ACTION); 
      // 결과 : { type: MY_ACTION }
      
      // payload 추가하기
      const action = myAction('hello');
      // 결과 : { type: MY_ACTION, payload: 'hello' }
      
      -----------------------------------------------------
      
      // payload 정의 함수 사용 => payload 변형 가능
      const myAction = createAction(MY_ACTION, text => `${text}!`);
      const action = myAction('hello');
      // 결과 : { type: MY_ACTION, payload: 'hello!' }
  • handleActions(updateObject, initialState)
    • 리듀서 함수를 더 간단하고 가독성 높게 작성할 수 있도록 도와주는 함수

    • updateObject : 액션에 따라 실행 할 함수들을 가지고있는 객체

    • initialState : 기본상태값

    • const counter = handleActions(
          {
              [INCREASE]: (state, action) => ({number: state.number + 1}),
              [DECREASE]: (state, action) => ({number: state.number - 1})
          },
          initialState
      )
      
      // modules/Todos.js 리듀서 예
      // 액션 생성 함수는 액션에 필요한 추가 데이터를 payload라는 이름으로 사용하기 때문에
      // 모두 공통적으로 action.payload로 값을 조회하도록 구현해야한다.
      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
      )
      
      // 비구조화 할당 문법으로 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
      )

immer 사용하기

immer 사용법

import produce from 'immer';

const nextState = produce(currentState, draft => {
	draft.somwwhere.deep.inside = 5;
})
  • produce(currentState, recipe) : 상태 불변성을 유지하여 새로운 상태를 생성하는 함수
    • currentState : 수정하고 싶은 상태
    • recipe : 첫 번째 파라미터인 currentState를 어떻게 업데이트할지 정의하는 함수. recipe 함수는 원본 데이터(draft)를 매개변수로 전달받는다. 이 함수 내부에서는 불변성에 신경 쓰지 않는 것처럼 코드를 작성하여 상태를 변경한다.
    • produce 함수는 recipe 함수 내부에서 변경한 상태를 바탕으로 새로운 상태를 반환한다.

immper 적용 예

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
)

Hooks를 사용하여 컨테이너 컴포넌트 만들기

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

useSelector

const 결과 = useSelector(상태 선택 함수);
  • connect 함수를 사용하지 않고도 리덕스의 상태를 조회할 수 있게 해주는 Hook
  • 상태 선택 함수는 connect 함수의 매개변수인 mapStateToProps와 형태가 똑같다.
  • 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;

useDispatch

const dispatch = useDispatch();

dispatch({type: 'SAMPLE_ACTION'});
  • 컴포넌트 내부에서 스토어의 내장 함수인 dispatch를 사용할 수 있게 해주는 Hook
  • 컴포넌트 최적화를 위해 useCallback과 함께 사용하는 것이 좋다.
  • 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 사용
    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} 
            />
        );
    };
  • 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 TodosContainers = () => {
        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}
            />
        );
    };

useStore

const store = useStore();
store.dispatch({type: 'SAMPLE_ACTION'});
store.getState();
  • 컴포넌트 내부에서 리덕스 스토어 객체를 직접 사용할 수 있게 해주는 Hook.
  • 자주 사용하지 않는 것을 권장. 스토어에 직접 접근해야하는 상황에서만 사용할 것을 권장.

useActions

  • useACtions Hook은 원래 react-redux에 내장된 상태로 릴리즈 될 계획이었으나 리덕스 개발 팀에서 꼭 필요하지 않다고 판단하여 제외되었다. 그 대신 공식 문서에서 그대로 복사하여 사용할 수 있도록 제공하고 있다.
  • useActions 코드 (src/lib/useActions.js로 저장)
    import { bindActionCreators } from 'redux'
    import { useDispatch } from 'react-redux'
    import { useMemo } from 'react'
    
    export 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] : [dispatch]
      )
    }
  • useAcrions 사용 예제
    import React, { useCallback } from 'react';
    import { useSelector } from 'react-redux';
    import { changeInput, insert, toggle, remove } from '../modules/Todos';
    import Todos from '../components/Todos';
    import { useActions } from '../lib/useActions';
    
    const TodosContainers = () => {
        const { input, todos } = useSelector(({todos}) => ({
            input: todos.input,
            todos: todos.todos
        }));
    
    		// useActions : 액션 생성 함수로 이루어진 배열과 dependency 배열을 전달받아
    		// 액션을 디스 패치하는 함수 배열을 반환하는 함수
    		// dependency 배열 원소가 바뀌면 액션을 디스패치하는 함수를 새로 만든다.
        const [ onChangeInput, onInsert, onToggle, onRemove ] = useActions(
            [changeInput, insert, toggle, remove],
            []
        )
        
        return (
            <Todos
                input={input}
                todos={todos}
                onChangeInput={onChangeInput}
                onInsert={onInsert}
                onToggle={onToggle}
                onRemove={onRemove}
            />
        );
    };

connect 함수와 react-redux Hooks와의 차이점

  • connect 함수를 사용하여 컨테이너 컴포너트를 만들 경우, 해당 컨테이너 컴포넌트의 부모 컴포넌트가 리랜더링 될 때 해당 컴포넌트의 props가 바뀌지 않는다면 리랜더링이 자동으로 방지 되어 성능이 최적화 된다.
  • 하지만 useSelector를 사용하여 리덕스 상태를 조회했을 때는 이 최적화 작업이 이루어지지 않으므로, 성능 최적화를 위해 React.memo를 사용해야 한다.

정리

  1. 모듈
    • 액션 타입 선언(상수), 액션 생섬 함수 선언(createAction from redux-actions), 초깃값 선언, 리듀서 선언(handleActions from redux-actions)
    • react-actions로 액션생성함수와 리듀서를 편하게 만들 수 있다.
  2. 루트 리듀서
    • combineReducers(from redux) 함수로 루트 리듀서 생성
  3. index.js
    • createStore 함수(from redux)의 매개변수로 rootReducer를 전달하여 store 생성
    • Provider 컴포넌트(from react-redux)로 App 컴포넌트 감싸고, props로 store 전달
  4. 프레젠테이셔널 컴포넌트
  5. 컨테이너 컴포넌트
    1. react-redux에서 제공하는 connect 함수로 컴포넌트와 스토어를 연결
    2. react-redux에서 제공하는 Hook을 사용하여 컴포넌트의 스토어를 연결
profile
애기 프론트 엔드 개발자

0개의 댓글