기존 메인 프로젝트는 MST(MobxStateTree)를 사용하여 상태를 관리했다. 이는 MST의 의도와 맞게 단순히 Clent State만을 다뤘으며, 비동기 데이터 동기화와 같은 Server State 처리는 반복적인 코드 작성으로 직접 처리중이었다. (굉장히 비효율적)
그러다 잠깐 프리랜서로 다른 프로젝트를 진행했을 때, Zustand와 react-query를 동시에 사용하고있는 것을 발견했는데, 개발중이던 코드를 이어받아 진행하게되어 흔적만 있을 뿐 제대로된 사용은 못하고있던 것을 알 수 있었다.
하지만 두개의 상태 관리 라이브러리를 사용하는 코드는 많은 궁금증을 유발하였고, 이렇게 해야하는 이유를 고민해보게되었다. 지금 생각해보면 Client State와 Server State를 효율적으로 관리하기 위함임을 알 수 있다.
프로젝트 규모가 커질수록 상태관리의 필요성은 점점 커지기 마련이다. prop drilling, 빈번한 API호출, 비동기 데이터의 동기화 등을 처리해야하기 때문이다.
상태관리를 하다보면 프레임워크 자체의 기능만으로는 불편함을 느껴 상태관리 라이브러리(Redux, Recoil, Zustand, Mobx, react-query ...)를 도입하게 된다.
이는 분명 상태관리에 큰 편리함을 가져다준다. 하지만 잘못 사용하게되면 배보다 배꼽이 더 커지는 상황(boilerplate의 비대함 등..)이 찾아온다.
이러한 문제상황을 해결하기 위해 '상태'를 Client State와 Server State로 나눠 생각하기로 한다. (더 완벽한 해결책을 위해 변화하는 건 당연하다 생각한다.)
참고 블로그_우아콘 2023
참고 블로그
react-query는 서버 상태 관리에 필요한 유용한 기능들을 제공한다. 서버 상태 관리에 가장 필요한 부분은 데이터 동기화라고 할 수 있다.
따로 서버 상태 관리없이 MST만을 사용하게되면 상태를 불러와 컴포넌트를 업데이트하는 데까지 동기화, pending처리, error처리 등 불필요한 코드 작성이 많아진다.
하지만 react-qeury에서는 아래와 같이 이를 해결해줄 강력한 기능을 제공한다.
function Menus() {
...
// "/menu" API에 Get 요청을 보내 서버의 데이터를 가져옵니다.
// isPending은 데이터가 모두 넘어왔는지 판단할 수 있습니다.
// isError은 에러핸들링에 사용될 수 있습니다.
const { data, isPending, isError } = useQuery('getMenu', () =>
axios.get('/menu').then(({ data }) => data),
);
// "/menu" API에 Post 요청을 보내 서버에 데이터를 저장합니다.
const { mutate } = useMutation(
(suggest) => axios.post('/menu', { suggest }),
{
// Post 요청이 성공하면 위 useQuery의 데이터를 초기화합니다.
// 데이터가 초기화되면 useQuery는 서버의 데이터를 다시 불러옵니다.
onSuccess: () => queryClient.invalidateQueries('getMenu'),
},
);
...
return(
...
}
아래는 Zustand를 이용한 customHook 예시 코드이다. state와 function을 쉽게 정의하고 사용할 수 있다.
// person.tsx
const usePersonStore = create<State & Action>((set) => ({
firstName: '',
lastName: '',
updateFirstName: (firstName) => set(() => ({ firstName: firstName })),
updateLastName: (lastName) => set(() => ({ lastName: lastName })),
}))
참고 블로그_1
참고 블로그_2
예제를 통해 실제 컴포넌트에서 이를 어떻게 활용하는지 확인해보겠다.
useQuery
를 사용하여 fetching(get)하는 customHook을 작성했다.
import {AxiosError} from 'axios';
import {useQuery, UseQueryOptions} from 'react-query';
...
export const getAllProducts = async () => {
return (await api.get<Array<Product>>('/products')).data;
};
export const useGetAllProducts = (
options?: UseQueryOptions<
Array<Product>,
AxiosError,
Array<Product>,
readonly [string]
>,
) => {
return useQuery({
queryKey: [...productKeyFactory.products],
queryFn: getAllProducts,
...options,
});
};
store의 schema를 정의하고, 이를 토대로 실질적으로 사용되는 customHook을 작성한다.
Zustand를 잘 사용하는 법은 -> 여기를 참고한다.
import {create} from 'zustand';
import {shallow} from 'zustand/shallow';
import {Product} from '../api/product';
...
// Store의 schema를 정의한다.
// productsInBasket로 정의된 object와 function역할인 actions로 나눠진다고 보면 된다.
export interface ProductStore {
productsInBasket: Array<ProductInBasket>;
actions: {
addProductToBasket: (val: Product) => void;
removeProductFromBasket: (productId: number) => void;
increaseProductQuantityInBasket: (productId: number) => void;
decreaseProductQuantityInBasket: (productId: number) => void;
resetAllProductsInBasket: () => void;
};
}
// 위 스키마를 토대로, 실질적으로 사용되는 hook을 정의한다.
export const useProductStore = create<ProductStore>((set, get) => ({
productsInBasket: [],
actions: {
addProductToBasket: product =>
set({
productsInBasket: [
...get().productsInBasket,
{product: product, quantity: 1},
],
}),
removeProductFromBasket: productId =>
set({
productsInBasket: [
...get().productsInBasket.filter(
productInBasket => productInBasket.product.id !== productId,
),
],
}),
increaseProductQuantityInBasket: productId => {
set({
productsInBasket: increaseProductQuantityInBasket(
get().productsInBasket,
productId,
),
});
},
resetAllProductsInBasket: () => set({productsInBasket: []}),
decreaseProductQuantityInBasket: productId => {
set({
productsInBasket: decreaseProductQuantityInBasket(
get().productsInBasket,
productId,
),
});
},
},
}));
// store의 특정 부분만을 사용한다.(selector)
// 이렇게 사용해야되는 이유는 위 링크를 참고한다.
export const useProductActions = () => useProductStore(state => state.actions);
export const useProductsInBasket = () =>
useProductStore(state => state.productsInBasket, shallow);
...
import {Product, useGetAllProducts} from '../api/product';
import {useProductActions, useProductsInBasket} from '../store/product';
...
const ProductListScreen: React.FC<Props> = ({navigation}) => {
// query
const {data, isLoading, refetch, isError, isSuccess} = useGetAllProducts();
const {isRefetchingByUser, refetchByUser} = useRefreshByUser(refetch);
// store
const {addProductToBasket, removeProductFromBasket} = useProductActions();
const productsInBasket = useProductsInBasket();
// press시, store에 해당 product유무에 따라 store action 실행
const onAddToBasketPress = React.useCallback(
(product: Product) => () => {
if (
productsInBasket.find(
productInBasket => productInBasket.product.id === product.id,
)
) {
removeProductFromBasket(product.id);
} else {
addProductToBasket(product);
}
},
[addProductToBasket, productsInBasket, removeProductFromBasket],
);
// FlatList에서 레더되는 내용
const renderItem = ({item: product}: ListRenderItemInfo<Product>) => {
return (
<ProductListCard
{...product}
testID={`product-list-card-${product.id}`}
basketButtonTestID={`basket-button-${product.id}`}
isInBasket={
typeof productsInBasket.find(
productInBasket => productInBasket.product.id === product.id,
) !== 'undefined'
}
// interaction에서 store action이 사용되는 것을 볼 수 있다.
onAddToBasketPress={onAddToBasketPress(product)}
onPress={onProductCardPress(product.id)}
/>
);
};
...
if (isLoading) {
return <ScreenLoading />;
}
return (
...
{isSuccess && (
<FlatList<Product>
// query를 통해 전달받은 data사용
data={data}
testID="product-list-flat-list"
refreshControl={
<RefreshControl
refreshing={isRefetchingByUser}
onRefresh={refetchByUser}
/>
}
// data를 render에 사용하며 각 interact은 store의 action으로 동작함.
renderItem={renderItem}
numColumns={2}
ItemSeparatorComponent={renderItemSeparator}
columnWrapperStyle={styles.columnWrapper}
contentContainerStyle={styles.contentContainerStyle}
showsVerticalScrollIndicator={false}
keyExtractor={getKeyExtractor}
style={COMMON_STYLES.flex}
/>
)}
...
)
}