단의 개발 블로그

리덕스 본문

Web/React

리덕스

danso 2024. 11. 4. 16:54

전역 상태관리

리액트에서 상태관리는 부모 컴포넌트가 진행한다. 그러면 결국 app 루트에서 자식 컴포넌트로 전달 과정을 여러번 거치게 된다. 이를 방지하고자 전역에서 상태 관리하는 방법이 고안됐다. 단순히 ContextAPI로 작업을 해도 되지만, 보통 리덕스라는 리액트 상태 관리 라이브러리를 많이 사용한다. 리덕스를 사용하면 컴포넌트의 상태 업데이트 관련 로직을 다른 파일로 분리시켜서 더욱 효율적으로 관리할 수 있기 때문이다.

 

액션

상태에 어떤 변화가 필요하면 액션이란 것이 발생한다. 하나의 객체로 표현된다.

 

리듀서

변화를 일으키는 함수다. 액션을 만들어서 발생시키면 리듀서가 현재 상태와 전달받은 액션 객체를 파라미터로 받아온다. 그리고 두 값을 참고하여 새로운 상태를 만들어 반환한다.

 

스토어

프로젝트에 리덕스를 적용하기 위해 스토어를 생성한다. 한개의 프로젝트에는 단 하나의 스토어가 존재한다. 현재 애플리케이션 상태와 리듀서가 들어가 있으며 그 외에 내장 함수를 지닌다.

 

디스패치

스토어의 내장 함수중 하나이다. 액션을 발생 시키는 것으로 이 함수는 액션 객체를 파라미터로 넣어서 호출한다.

 

구독

스토어의 내장 함수중 하나이다. subscribe 함수 안에 리스너 함수를 파라미터로 넣어 호출해주면, 이 리스너 함수가 액션이 디스패치되어 상태가 업데이트 될 때마다 호출된다.

 

사용하기

리덕스를 사용할 때 가장 많이 사용하는 패턴은 프레젠테이셔널 컴포넌트와 컨테이너 컴포넌트를 분리하는 것이다. 프레젠테이셔널 컴포넌트란 주로 상태 관리가 이루어지지 않고 props를 받아와서 화면에 보여주기만 하는 컴포넌트를 의미한다. 컨테이너 컴포넌트는 리덕스와 연동되어 있는 컴포넌트로 리덕스로부터 상태를 받아오기도 하고 리덕스 스토어에 액션을 디스패치 하기도 한다. 꼭 이렇게 사용하지 않아도 되지만 관심사를 분리하기 때문에 유지보수가 편리해진다.

 

먼저 Counter, Todos 컴포넌트를 생성한다.

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

export default Counter;

 

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

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

export default Todos;

리덕스 작성 방식에는 두가지가 있다. 하나는 액션 타입, 액션 생성 함수, 리듀서 코드를 작성할 때 각각 다른 파일에 작성하는 방법과 기능별로 묶어서 파일 하나에 작성하는 방법이다. actions, constants, reducers라는 세개의 디렉토리를 만들고 그 안에 기능별로 파일을 만드는 방법인데, 코드 종류에 따라 다른 파일에 작성하기 때문에 정리하기 좋지만 새로운 액션 생성 시 세 종류의 파일을 모두 수정해야 하는 번거로움이 있다. 다른 방법으론 하나에 몰아서 작성하는 방법인데 이러한 패턴을 Ducks 패턴이라고 부른다. 여기서는 Ducks패턴을 사용하여 개발한다.

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

const rootReducer = combineReducers({
    counter,
    todos
})

export default rootReducer;
const INCREASE = 'counter/INCREASE';
const DECREASE = 'counter/DECREASE';

export const increase = () => ({type: INCREASE});
export const decrease = () => ({type: 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;
    }
}

export default counter;
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: true
        }
    ]
}

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;

스토어 생성하기 index js에 rootReducer를 추가하고 Proveider로 스토어 속성에 스토어 상태를 추가한다.

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

리덕스 스토어에 접근하여 원하는 상태를 받아오고 액션에 디스패치 해주는 컨테이너 컴포넌트를 생성한다. connect 함수가 리덕스와 연동해서 사용하는 함수로, mapStateToProps는 리덕스 스토어 안의 상태를 컴포넌트 props로 넘겨주기 위해 설정하는 함수이고, mapDispatchToProps는 액션 생성함수를 컴포넌트의 props로 넘겨주기 위해 사용하는 함수다.

import Counter from "./Counter";
import {decrease} from "../modules/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);

위으 코드를 아래와 같이 변경할 수도 있다.

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

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

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

bindActionCreators를 사용하면 좀 더 간략하게 줄일 수 있다.

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

mapDispatchToProps에 해당하는 파라미터를 함수 형태가 아닌 액션 생성 함수로 이루어진 객체 형태로 넣어주는 것도 가능하다.

export default connect(
    state => ({
        number: state.counter.number,
    }),
    {
        increase,
        decrease
    },
)(CounterContainer);
import Todos from "./Todos";
import {changeInput} from "../modules/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}) => ({
        input: todos.input,
        todos: todos.todos,
    }),
    {
        changeInput,
        insert,
        toggle,
        remove,
    }
)(TodosContainer);

 

리덕스를 더 편하게 사용하는 방법

redux-actions와 immer 라이브러리를 활용하면 리덕스를 더 편하게 사용할 수 있다.  createAction으로 액션을 간단하게 등록하고, handleActions 함수로 액션을 지정할 수 있다.

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

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

export const increase = createAction(INCREASE)
export const decrease = createAction(DECREASE);

const initialState = {
    number: 0
}

const counter = handleAction({
    [INCREASE]: (state, action) => ({number: state.number + 1}),
    [DECREASE]: (state, action) => ({number: state.number - 1}),
})

export default counter;

각 액션 생성 함수에서 파라미터를 필요로 할 때 다음과 같이 변경할 수 있다. 액션에 필요한 추가 데이터는 payload 라는 이름을 사용해서 만든다. 아니면 payload를 정의하는 함수를 따로 선언해서 넣어주면 된다.


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

let id = 3;
export const insert = createAction(INSERT, text => ({
    id: id++,
    text,
    done: false
}));
export const toggle = createAction(TOGGLE, id => id);
export const remove = createAction(REMOVE, id => id);
const todos = handleAction(
    {
        [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;

하지만 이렇게 작업할 경우 코드 가독성이 떨어지게 된다. payload를 각각의 액션 함수에서 사용하는 변수에 맞게 변형해준다.

const todos = handleAction(
    {
        [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;

 

immer는 불변성을 지키기 위해 간편하게 사용할 수 있는 라이브러리다. 모듈의 상태를 설계할 때 객체의 깊이가 너무 깊어지지 않도록 주의해야 한다.

const todos = handleAction(
    {
        [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 사용한 컨테이너 컴포넌트

useSelector : connect함수를 사용하지 않고도 리덕스 상태를 조회할 수 있다. const 결과 = useSelector(상태선택함수)

useDispatch : 컴포넌트 내부에서 스토어의 내장 함수 dispatch를 사용할 수 있게 해준다. const dispatch = useDispatch() dispatch({type: 'action'})

useStore : 컴포넌트 내부에서 리덕스 스토어 객체를 직접 사용할 수 있다. const store = useStore() store.dispatch({type:'action'}) store.getState()

 

'Web > React' 카테고리의 다른 글

코드 스플리팅  (0) 2024.11.05
리덕스 미들웨어  (0) 2024.11.05
라우팅  (0) 2024.11.03
Hooks  (0) 2024.11.01
이벤트  (0) 2024.11.01