최근에 작성한 것 중, 자랑하고 싶은 코드를 정리한 문서입니다.
- model.ts
- action.ts
- service.ts
- epic.ts
- epic.spec.ts
- reducer.ts
- reducer.spec.ts
- selector.ts
- App.tsx
- UserLabel.tsx
- 위 설계에 따라 만든 장바구니 소스 코드
유저 정보를 API로 호출하여 받아온 후 스토어에서 관리하는
비동기 작업을 redux-observable을 통해 수행하는 코드입니다.
넷플릭스의 설계를 참고했습니다.
이를 이용해 마우스 호버 시 유저 정보의 Popover를 띄워주는
UserLabel 스마트 컴포넌트를 만들었습니다.
export class UserModel {
userIdx!: number;
name!: string;
email!: string;
phoneNumber!: string;
}
import { createAction, createAsyncAction, ActionType } from 'typesafe-actions';
import { UserModel } from 'app/model';
export const authLogout = createAction('AUTH_LOGOUT')();
export const fetchUserAsync = createAsyncAction(
'FETCH_USER_REQUEST',
'FETCH_USER_SUCCESS',
'FETCH_USER_FAILURE',
)<
{
userIdx: UserModel['userIdx'];
},
UserModel,
{
userIdx: UserModel['userIdx'];
errMsg: string;
}
>();
export type Action =
| ActionType<typeof authLogout>
| ActionType<typeof fetchUserAsync>;
import { OperatorFunction } from 'rxjs';
import { map } from 'rxjs/operators';
import { AxiosResponse } from 'axios';
import AxiosObservable from 'axios-observable';
import { UserModel } from 'app/model';
import { API_ENDPOINT } from 'app/config';
export const storageService = {
getItem(key: string) {
return localStorage.getItem(key);
},
setItem(key: string, value: string) {
return localStorage.setItem(key, value);
},
clear() {
return localStorage.clear();
},
};
const httpClient = AxiosObservable.create({
baseURL: API_ENDPOINT,
});
httpClient.interceptors.request.use((config) => {
const token = storageService.getItem('token');
config.headers.Authorization = token ? `Bearer ${token}` : '';
return config;
});
type ApiResponse<T> = { data: T };
type ProxyAxiosResponse<T> = T extends AxiosResponse<{ data: infer D }> ? D : never;
const mapApiResponse: <
T extends AxiosResponse<ApiResponse<R>>,
R = ProxyAxiosResponse<T>
>() => OperatorFunction<
T,
R
> = () => map(({ data: { data } }) => data);
export const userService = {
getUser(userIdx: UserModel['userIdx']) {
return httpClient
.get<ApiResponse<UserModel>>(`/users/${userIdx}`)
.pipe(mapApiResponse());
},
};
import { of, empty } from 'rxjs';
import { map, filter, catchError, mergeMap, finalize } from 'rxjs/operators';
import { ActionsObservable, combineEpics, Epic } from 'redux-observable';
import { isActionOf } from 'typesafe-actions';
import { Action, fetchUserAsync } from 'app/action';
import * as service from 'app/service';
type Service = typeof service;
export const fetchUserEpic: Epic = (
actions$: ActionsObservable<Action>,
_,
{ userService }: Service,
) => {
// NOTE: API 요청 중인 userIdx를 inProgress에 임시 저장하여, 같은 유저의 정보를 동시에 요청하는 일이 없도록 한다.
const inProgress: Record<number, boolean> = {};
return actions$.pipe(
filter(isActionOf(fetchUserAsync.request)),
mergeMap(({ payload: { userIdx } }) => {
if (inProgress[userIdx]) {
return empty();
}
inProgress[userIdx] = true;
return userService.getUser(userIdx).pipe(
map((data) => fetchUserAsync.success(data)),
catchError((err) =>
of(
fetchUserAsync.failure({
userIdx,
errMsg: err.message,
}),
),
),
finalize(() => {
inProgress[userIdx] = false;
}),
);
}),
);
};
export const rootEpic = combineEpics(fetchUserEpic);
import { Subject, of, throwError } from 'rxjs';
import { Action } from 'redux';
import { ActionsObservable, StateObservable } from 'redux-observable';
import { mocked } from 'ts-jest/utils';
import { fetchUserAsync } from 'app/action';
import * as service from 'app/service';
import { fetchUserEpic } from 'app/epic';
jest.mock('app/service');
describe('epic 테스트', () => {
describe('fetchUserEpic 테스트', () => {
const mockedUserService = mocked(service.userService, true);
test('유저 정보 조회가 성공하면 success 액션이 발생해야 한다.', (done) => {
// <!-- mock
mockedUserService.getUser.mockReturnValueOnce(
of({
userIdx: 105,
name: 'ghojeong',
email: 'gho@email.com',
phoneNumber: '+821012345678',
}),
);
// -->
const actions$ = ActionsObservable.of(fetchUserAsync.request({ userIdx: 105 }));
const state$ = new StateObservable(new Subject(), {});
const dependencies = { userService: mockedUserService };
const actualActions: Action[] = [];
fetchUserEpic(actions$, state$, dependencies).subscribe({
next: (action: Action) => actualActions.push(action),
complete: () => {
expect(actualActions).toEqual([
fetchUserAsync.success({
userIdx: 105,
name: 'ghojeong',
email: 'gho@email.com',
phoneNumber: '+821012345678',
}),
]);
done();
},
});
});
test('유저 정보 조회가 실패하면 failure 액션이 발생해야 한다.', (done) => {
// <!-- mock
mockedUserService.getUser = jest.fn().mockImplementation(
() => throwError(new Error('getUser Error'))
);
// -->
const actions$ = ActionsObservable.of(fetchUserAsync.request({ userIdx: 105 }));
const state$ = new StateObservable(new Subject(), {});
const dependencies = { userService: mockedUserService };
const actualActions: Action[] = [];
fetchUserEpic(actions$, state$, dependencies).subscribe({
next: (action) => actualActions.push(action),
complete: () => {
expect(actualActions).toEqual([
fetchUserAsync.failure({
userIdx: 105,
errMsg: 'getUser Error',
}),
]);
done();
},
});
});
});
});
import { combineReducers } from 'redux';
import { getType } from 'typesafe-actions';
import { UserModel } from 'app/model';
import { Action, authLogout, fetchUserAsync } from 'app/action';
export type UserState = Record<
UserModel['userIdx'],
{
isLoading: boolean;
errMsg: string | null;
item?: UserModel; // NOTE: 데이터를 한 번도 받아오지 않았다면 비어있을 수 있다.
}
>;
export const userInitialState: UserState = {};
export const userReducer = (
userState = userInitialState,
action: Action,
): UserState => {
switch (action.type) {
case getType(authLogout):
return userInitialState;
case getType(fetchUserAsync.request):
return {
...userState,
[action.payload.userIdx]: {
...(userState[action.payload.userIdx] || {}),
isLoading: true,
errMsg: null,
},
};
case getType(fetchUserAsync.success):
return {
...userState,
[action.payload.userIdx]: {
...(userState[action.payload.userIdx] || {}),
isLoading: false,
errMsg: null,
item: action.payload,
},
};
case getType(fetchUserAsync.failure):
return {
...userState,
[action.payload.userIdx]: {
...(userState[action.payload.userIdx] || {}),
isLoading: false,
errMsg: action.payload.errMsg,
},
};
default:
return userState;
}
};
export interface RootState {
user: UserState;
}
export const rootReducer = combineReducers<RootState>({ user: userReducer });
import { authLogout, fetchUserAsync } from 'app/action';
import { UserState, userReducer, userInitialState } from 'app/reducer';
describe('userReducer 테스트', () => {
test('authLogout 액션이 발행되면 userState가 초기값으로 세팅 되어야 한다.', () => {
const userState: UserState = {
105: {
isLoading: false,
errMsg: null,
item: {
userIdx: 105,
name: 'ghojeong',
email: 'gho@email.com',
phoneNumber: '+821012345678',
},
},
};
const action = authLogout();
expect(userReducer(userState, action)).toEqual(userInitialState);
});
test('fetchUserAsync를 request 하면 isLoading 이 true가 되어야 한다.', () => {
const userState: UserState = userInitialState;
const action = fetchUserAsync.request({ userIdx: 105 });
expect(userReducer(userState, action)).toEqual({
105: {
isLoading: true,
errMsg: null,
},
});
});
test('fetchUserAsync가 성공하면 userState에, 받아온 user이 추가 되어야 한다.', () => {
const userState: UserState = userInitialState;
const action = fetchUserAsync.success({
userIdx: 105,
name: 'ghojeong',
email: 'gho@email.com',
phoneNumber: '+821012345678',
});
expect(userReducer(userState, action)).toEqual({
105: {
isLoading: false,
errMsg: null,
item: {
userIdx: 105,
name: 'ghojeong',
email: 'gho@email.com',
phoneNumber: '+821012345678',
},
},
});
});
test('fetchUserAsync가 실패하면 errMsg가 세팅 되어야 한다.', () => {
const userState: UserState = userInitialState;
const action = fetchUserAsync.failure({
userIdx: 105,
errMsg: 'fetchUserAsync Failed',
});
expect(userReducer(userState, action)).toEqual({
105: {
isLoading: false,
errMsg: 'fetchUserAsync Failed',
},
});
});
});
import { UserModel } from 'app/model';
import { RootState } from 'app/reducer';
export const userSelectorByIdxFactory =
(userIdx: UserModel['userIdx']) =>
({ user }: RootState) =>
user[userIdx];
import React from 'react';
import { hot } from 'react-hot-loader';
import { Provider } from 'react-redux';
import { createStore, applyMiddleware } from 'redux';
import { createEpicMiddleware } from 'redux-observable';
import { composeWithDevTools } from 'redux-devtools-extension';
import { userService } from 'app/service';
import { rootEpic } from 'app/epic';
import { rootReducer } from 'app/reducer';
import { AppRouter } from 'app/route';
const service = { userService };
const epicMiddleware = createEpicMiddleware({
dependencies: service,
});
const store = createStore(
rootReducer,
composeWithDevTools(applyMiddleware(epicMiddleware)),
);
epicMiddleware.run(rootEpic);
const App = () => (
<Provider store={store}>
<AppRouter />
</Provider>
);
export default hot(module)(App);
import React, { FC, useMemo, useEffect, useState, MouseEvent } from 'react';
import { useSelector, useDispatch } from 'react-redux';
import Button from '@material-ui/core/Button';
import Popover from '@material-ui/core/Popover';
import Typography from '@material-ui/core/Typography';
import Skeleton from '@material-ui/lab/Skeleton';
import ErrorIcon from '@material-ui/icons/Error';
import RefreshIcon from '@material-ui/icons/Refresh';
import { UserModel } from 'app/model';
import { fetchUserAsync } from 'app/action';
import { userSelectorByIdxFactory } from 'app/selector';
interface PropTypes {
userIdx: UserModel['userIdx'];
}
export const UserLabel: FC<PropTypes> = ({ userIdx }) => {
const dispatch = useDispatch();
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
// NOTE: userIdx가 바뀌지 않으면 셀렉터를 다시 만들지 않는다.
const userSelector = useMemo(() => userSelectorByIdxFactory(userIdx), [userIdx]);
const user = useSelector(userSelector);
const handlePopoverOpen = (event: MouseEvent<HTMLElement>) => {
setAnchorEl(event.currentTarget);
};
const handlePopoverClose = () => {
setAnchorEl(null);
};
useEffect(() => {
dispatch(fetchUserAsync.request({ userIdx }));
}, [dispatch, userIdx]);
const hasErrMsg = user && !user.isLoading && user.errMsg;
if (hasErrMsg) {
return (
<Typography noWrap component="div" variant="body2" color="error">
<ErrorIcon />
<span>{user.errMsg}</span>
<Button
variant="contained"
onClick={() => {
dispatch(fetchUserAsync.request({ userIdx }));
}}
>
Refresh
<RefreshIcon />
</Button>
</Typography>
);
}
if (user.item) {
const { name, email, phoneNumber } = user.item;
return (
<>
<span
onMouseEnter={handlePopoverOpen}
onMouseLeave={handlePopoverClose}
>
{name}
</span>
<Popover
open={Boolean(anchorEl)}
anchorEl={anchorEl}
onClose={handlePopoverClose}
anchorOrigin={{
vertical: 'center',
horizontal: 'right',
}}
transformOrigin={{
vertical: 'center',
horizontal: 'left',
}}
disableRestoreFocus
>
<div>
<div>Email: {email}</div>
<div>Phone Number: {phoneNumber}</div>
</div>
</Popover>
</>
);
}
return <Skeleton width={80} height={8} />;
};