리액트 프로젝트에서 useContext와 useState, useReducer를 활용하여 상태관리를 진행해왔다. 하지만 이들 만으로는 약간의 아쉬움이 존재했다. 특히 전역 상태 관리를 도와주는 Context의 경우 하위 컴포넌트가 모두 리렌더링되기에 Provider마다 하나의 상태값만을 주게되어 전역 상태가 늘어날 수록 추가되는 Provider로 번거로움과 코드 가독성이 좋지 못함을 경험하게 되었다.
전역 상태관리 라이브러리를 사용해보고 싶어졌다.
그렇다면 어떤 라이브러리를 선택해야 할까?
프로젝트 규모와 팀원들과의 이해관계가 필요하겠지만 학습 단계인 나는 가장 많이 사용되고 있는 Redux를 적용해 보려고 한다.
Redux를 사용하기에 앞서 Redux가 지향하고자 하는 철학에 대해서 알아보자.
리덕스는 애플리케이션의 모든 상태를 하나의 스토어(store) 내에 있는 하나의 객체 트리(tree)에 저장한다. 이것은 애플리케이션의 상태를 예측 가능하게 만들고, 디버깅과 검사를 용이하게 한다.
상태를 변경할 수 있는 유일한 방법은 액션(action)을 발행(emit)하는 것이다. 액션은 무엇이 일어나야 하는지를 설명하는 일반 객체다. 이러한 제한은 상태 변경을 일관되게 추적할 수 있게 해주며, 애플리케이션에서 예측 가능한 동작을 보장한다.
액션에 의해 상태 트리가 어떻게 변화하는지를 지정하기 위해 리듀서(reducer)라 불리는 순수 함수를 사용한다. 리듀서는 이전 상태와 액션을 인자로 받아 새로운 상태를 반환하는 함수다. 이 원칙은 상태 변화의 로직을 명확하고 예측 가능하게 만들며, 테스트와 디버깅을 용이하게 해준다.
위와 같은 철학은 단방향 상태관리 Flux 아키텍쳐를 기반으로 Redux가 발전해왔기 때문이다.
상태관리로 유명한 패턴 중 하나인 MVC 패턴의 경우 양방향 데이터 바인딩이 가진 복잡성과 예측 불가능성으로 view가 많이 필요한 프론트엔드 개발에서는 어려움이 많다. 이를 해결하기 위해, Flux 아키텍처의 단방향 데이터 흐름 개념을 가져와서 발전한게 Redux 라이브러리다.
// index.tsx
import React from 'react';
import ReactDOM from 'react-dom/client';
import { Provider } from 'react-redux';
import { configureStore } from '@reduxjs/toolkit';
import rootReducer from './modules';
import App from './components/App';
const store = configureStore({
reducer: rootReducer,
});
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
const root = ReactDOM.createRoot(
document.getElementById('root') as HTMLElement,
);
root.render(
<React.StrictMode>
<Provider store={store}>
<App />
</Provider>
</React.StrictMode>,
);
// modules/index.ts
import { combineReducers } from 'redux';
import usersReducer from './users';
const rootReducer = combineReducers({
usersReducer,
// + 다른 리듀서들
});
export default rootReducer;
SET_USERS
, ADD_USER
, UPDATE_USERS
에 대응하여 각각 다른 상태 변경을 수행하도록 설계되었다.// modules/users.ts
export const setUsers = (users: IUser[]) => ({
type: SET_USERS,
payload: users,
});
export const addUser = (user: IUser) => ({
type: ADD_USER,
payload: user,
});
export const updateUsers = (userIds: number[]) => ({
type: UPDATE_USERS,
payload: userIds,
});
const initialState: IUserDataState = {
users: [],
};
const usersReducer = (
state = initialState,
action: IUserDataActions,
): IUserDataState => {
switch (action.type) {
case SET_USERS:
return {
...state,
users: action.payload,
};
case ADD_USER:
return {
...state,
users: [...state.users, action.payload],
};
case UPDATE_USERS:
return {
...state,
users: state.users.map((user) =>
action.payload.includes(user.id)
? { ...user, isDeleted: !user.isDeleted }
: user,
),
};
default:
return state;
}
};
export default usersReducer;
const getUsersData = async (dispatch: AppDispatch) => {
try {
const res = await axiosInstance.get(`/user_data`);
if (res.status === 200) {
// 유저 데이터 반환
dispatch(setUsers(res.data));
return res.data;
}
} catch (err) {
throw err;
}
};
const addUserData = async (data: IUser, dispatch: AppDispatch) => {
try {
const res = await axiosInstance.post(`/user_data`, data);
if (res.status === 200) {
// 유저 추가
dispatch(addUser(res.data));
}
} catch (err) {
return err;
}
};
const updateUserData = async (
ids: number[],
updateValue: boolean,
dispatch: AppDispatch,
) => {
try {
const userToUpdate = { isDeleted: updateValue };
const queryString = ids.join(',');
const res = await axiosInstance.patch(
`/user_data?ids=${queryString}`,
userToUpdate,
);
if (res.status === 200) {
// 유저 상태 변경
dispatch(updateUsers(ids));
}
} catch (err) {
return err;
}
};
getUsersData(dispatch)
updateUserData(ids, false, dispatch);
addUserData(newUserData, dispatch);
const users = useSelector((state: IRootState) => state.users.users);
// jsx
users.map((user) => (
<Thumbnail
key={user.id}
user={user}
isChecked={checkedUserIds.includes(user.id)}
onCheckboxChange={onCheckboxChange}
isActive={isActive}
/>