TIL 22-05-03 (2)

thisisyjin·2022년 5월 3일

TIL 📝

목록 보기
38/113

React.js

Today I Learned ... react.js

🙋‍♂️ Reference Book

🙋‍ My Dev Blog


리액트를 다루는 기술 DAY 17

  • React + Redux

React + Redux Project

  • React에서 소규모 프로젝트에서는 state로도 충분하지만, 프로젝트 규모가 커지면 state 관리가 번거로움.
  • React에서 Redux를 사용하면, 상태 업데이트에 관한 로직을 모듈로 따로 분리하여 별개의 파일로 관리할 수 있음.
    -> 코드 유지보수 및 최적화 용이.
  • 여러 컴포넌트에서 동일한 상태를 공유해야할 때 매우 유용.

이전 포스팅에서 vanila JS 환경에서 리덕스를 사용할 때에는 store의 내장 함수인 store.dispatch, store.subscribe를 사용했음.

  • 리액트에서 리덕스를 사용할 때는, store객체를 직접 사용하기보다는
    react-redux라는 라이브러리의 유틸 함수 및 컴포넌트를 사용함.

redux, react-redux 설치

$ yarn add redux react-redux

UI 준비하기

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

프레젠테이셔널 컴포넌트컨테이너 컴포넌트
state 관리 X. 그저 props를 받아 화면에 UI를 보여주기만 함리덕스와 연동. 리덕스로부터 state를 받아오기도 하고,
store에 액션을 디스패치 하기도 함.

-> 이러한 패턴을 사용하면 코드 재사용성도 높아지고, UI 작성에 더 집중할 수 있다.

UI 관련 프레젠테이셔널 컴포넌트는 src/components에 저장하고,
Redux에 연동된 컨테이너 컴포넌트는 src/containers 컴포넌트에 작성함.


1) Counter 컴포넌트 생성

src/components/Counter.js

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;

App.js

import Counter from "./components/Counter";

function App() {
  return (
    <div>
      <Counter number={0} />
    </div>
  );
}

export default App;

2) Todos 컴포넌트

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.preventDefalt();
    };
    return (
        <div>
            <form onSubmit={onSubmit}>
                <input />
                <button type="submit">등록</button>
            </form>
            <div>
                <TodoItem />
                <TodoItem />
                <TodoItem />
                <TodoItem />
                <TodoItem />
            </div>
        </div>
    );
};

export default Todos;
  • 파일 하나에 두 컴포넌트를 선언함. (분리해도 OK)

3) App.js 렌더링

import Counter from "./components/Counter";
import Todos from "./components/Todos";

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

export default App;

리덕스 관련 코드 작성

  • 리덕스를 사용할 때는 액션 타입, 액션 생성함수, reducer 코드를 작성해야 함.
  • 각각 다른 폴더를 지정하여 작성할 수도 있음.

  • Redux 공식 문서에서도 사용되는 기본적 구조.
  • actions, constants, reducers 세 개의 디렉터리를 만들고 그 안에 기능별로 파일을 생성.
    -> 새로운 액션을 만들 때 마다 세종류의 파일을 모두 수정해야하는 번거로움이 O.

🐥 Ducks 패턴

  • 아래와 같이 액션타입 / 액션 생성함수 / 리듀서 함수를 기능별로 파일 하나로 몰아서 작성함.
  • 둘중 아무 패턴이나 사용해도 됨. (상관 X)

counter 모듈 작성

1) 액션 타입 + 생성 함수

// 액션 타입 
const INCREASE = 'counter/INCREASE';
const DECREASE = 'counter/DECREASE';

export const increase = () => ({ type: INCREASE });
export const decrease = () => ({ type: DECREASE });
  • 액션 타입은 대문자로 정의하고, 내용은 '모듈이름/액션이름'의 형태로 작성함.
    -> 다른 모듈과 액션이름이 중복되지 않도록.

  • 액션 생성함수는 export해줌.

2) initialState + reducer 생성

const INCREASE = 'counter/INCREASE';
const DECREASE = 'counter/DECREASE';


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

// 🔻 추가된 부분
const initialState = {
    number: 0
};

const 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;
  • initialState 값을 설정해준 후,
  • reducer 함수를 생성. (함수명은 구분을 위해 'counter'로 함.)
    -> export default 키워드로 함수를 export함.

✅ export vs. export default

  • export는 여러개를 내보낼 수 있지만
  • export default는 한개만 내보낼 수 있음.
  • import시에는 import counter로 한번에 불러올 수 있고,
    increase, decrease는 import {increase, decrease}로 불러와야함.

todos 모듈 작성

1) 액션 타입 + 생성 함수

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

let id = 3;

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

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

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

id는 전역변수 id로 관리해서 todo의 id로 사용하도록 함.
-> insert 함수가 호출될 때 마다 1씩 증가하도록. (id++)

2) initialState + reducer 생성

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

let id = 3;

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

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
        },
    ]
};

const 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;

루트 리듀서 생성

modules/index.js

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

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

export default rootReducer;

redux 라이브러리의 combineReducers함수를 이용하여 두개의 모듈을 합침.
-> createStore을 통해 스토어를 만들 때 하나의 리듀서를 사용해야 하기 때문.


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

  • redux 라이브러리의 createStore() 함수를 임포트함.

src/index.js

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

const store = createStore(rootReducer);

const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
    <App />
);

❗️ 루트 리듀서의 파일명을 index.js로 해주면, import시 폴더명까지만 적어도 된다!

import rootReducer from './modules';

Provider 컴포넌트 사용

  • react-redux의 Provider 컴포넌트를 임포트함.
  • Provider 컴포넌트 사용시 store을 props로 전달해주어야 함.
import React from 'react';
import ReactDOM from 'react-dom/client';
import { createStore } from 'redux';
import { Provider } from 'react-redux';
import App from './App';
import './index.css'
import rootReducer from './modules';

const store = createStore(rootReducer);

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

🙋‍♀️ 참고 - 이제는 createStore() 이 아닌 react-redux의 configureStore({}) 을 이용하자.


Redux DevTool

  • 크롬 확장프로그램 Redux DevTool로 설치한 후,
    아래와 같이 리덕스 스토어를 만드는 과정에서 적용 가능.
const store = createStore(
  rootReducer, /* preloadedState */
  window.__REDUX_DEVTOOLS_EXTENSION__ && window.__REDUX_DEVTOOLS_EXTENSION__()
);
  • 위 코드를 적용하는 것 보다는 패키지를 설치하면 훨씬 간편하게 사용 가능.

🛠 redux-devtools-extension 설치

$ yarn add redux-devtools-extension

src/index.js

import React from 'react';
import ReactDOM from 'react-dom/client';
import { createStore } from 'redux';
import { Provider } from 'react-redux';
import { composeWithDevTools } from 'redux-devtools-extension';
import App from './App';
import './index.css'
import rootReducer from './modules';

const store = createStore(rootReducer, composeWithDevTools);

const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
  <Provider store={store}>
    <App />
  </Provider>
);
  • Redux DevTool의 'state'탭을 살펴보면 아래와 같이 나온다.

🔻 참고로, 이렇게 차트형 or Raw(객체형식)으로도 볼 수 있다.


컨테이너 컴포넌트 생성

  • 리덕스 스토어에 접근하여 원하는 상태를 받아오고, 액션도 디스패치해줌.
  • 리덕스 스토어와 연동된 컴포넌트를 컨테이너 컴포넌트라고 한다.

1) CounterContainer 생성

src/containers/CounterContainer.js

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

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

export default CounterContainer; 

위 CounterContainer 컴포넌트를 리덕스와 연동하기 위해서는 react-redux 라이브러리의 connect함수를 이용해야함.

ex>

connect(mapStateToProps, mapDispatchToPRops)(CounterContainer);

connect(mapStateToProps, mapDispatchToProps)()

CounterContainer.js (수정)

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

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

const mapStateToProps = state => ({
    number: state.counter.number,
});

const mapDispatchToProps = dispatch => ({
    increase: () => {
        console.log('increase');
    },
    decrease: () => {
        console.log('decrease');
    },
});

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

mapStateToProps와 mapDispatchToProps에서 리턴하는 객체 내부의 값은 컴포넌트의 props로 전달됨.

  • mapStateToProps : state를 인자로 받아옴. = 현재 store가 지니고 있는 값.
  • mapDispatchToProps : store.dispatch() 함수를 파라미터로 받아옴.

App - Container 렌더링

App.js (수정)

import CounterContainer from "./containers/CounterContainer";
import Todos from "./components/Todos";

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

export default App;

-> Counter 대신 CounterContainer 컴포넌트 렌더링.

increase, decrease 기능 구현

CounterContainer.js

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

...

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

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

이제 +1, -1 버튼을 클릭하면 카운터가 변경된다.
Redux DevTool을 보면 아래와 같이 액션명이 나온다.

  • connect 함수 사용시, 일반적으로 위와 같이 mapStateToProps, mapDispatchToProps를 미리 정의해놓고 사용한다.
  • connect 안에 익명함수 형식으로 선언해도 괜찮다.
export default connect(state => ({
    number: state.counter.number,
}), dispatch => ({
    increase: () => dispatch(increase()),
    decrease: () => dispatch(decrease()),
})
)(CounterContainer);

bindActionCreator

  • 컴포넌트에서 액션을 dispatch로 감싸는 작업이 번거로움.
    액션 생성 함수가 많아질수록 더 번거로워짐.

  • redux 라이브러리에서 제공하는 유틸함수인 bindActionCreator을 사용해주면 됨.

CreateContainer.js

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

dispatch => bindActionCreators({increase,decrease}, dispatch})() 해줌.
-> 따로 Action을 불러오고 dispatch로 감싸줄 필요가 없어짐.

mapDispatchToProps - 쉬운 방법

  • mapDispatchToProps()의 인자에 액션 생성함수로 이루어진 객체를 넣어줌.
    -> 액션 생성함수를 요소로 갖는 객체.
...
export default connect(state => ({
    number: state.counter.number,
}),
{
    increase, decrease
}
)(CounterContainer);

2) TodosContainer 생성

  • connect 함수를 이용하고, mapDispatchToProps를 함수가 담긴 객체로 나타냄
import { connect } from "react-redux";
import { changeInput, insert, toggle, remove } from "../modules/todos";
import Todos from "../components/Todos";

const TodoContainer = ({ input, todos, changeInput, insert, toggle, remove }) => {

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

export default connect(
    // state.todos 가져옴
    ({ todos }) => ({
        input: todos.input,
        todos: todos.todos,
    }),
    {
        changeInput,
        insert,
        toggle,
        remove
    },
)(TodoContainer);

아까 connect의 두번째 인자인 mapDispatchToProps함수의 인자로 state가 갔었는데,
이 함수는 state를(=store가 가지고 있는 값) 인자로 가짐.
여기서도 마찬가지로 state를 가지지만, state.todos를 {todos}로 구조분해 할당해줌.

App - Container 렌더링

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

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

export default App;

Todos.js - props 사용

components/Todos.js

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.preventDefalt();
        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;

RESULT

profile
기억은 한계가 있지만, 기록은 한계가 없다.

0개의 댓글