Woongjae Lee
NHN Dooray - Frontend Team
Ducks Pattern
Connect with Hooks
react-router-dom 과 redux 함께 쓰기
redux-saga
redux-actions
Software Engineer | Studio XID, Inc.
Microsoft MVP
TypeScript Korea User Group Organizer
Electron Korea User Group Organizer
Marktube (Youtube)
git clone -b fc-school https://github.com/2woongjae/reactjs-books-review.git
cd reactjs-books-review
nvm use
npm ci
- create.js
- module1.js
- module2.js
...
- reducer.js (or index.js)
// src/redux/modules/books.js
import BookService from '../../services/BookService';
// 액션 타입 정의 ("app 이름"/"reducer 이름"/"로컬 ACTION_TYPE") => 겹치지 않게 하기 위함
const PENDING = 'reactjs-books-review/books/PENDING';
const SUCCESS = 'reactjs-books-review/books/SUCCESS';
const FAIL = 'reactjs-books-review/books/FAIL';
// 리듀서 초기값
const initialState = {
books: [],
loading: false,
error: null,
};
// 액션 생성자 함수
const start = () => ({ type: PENDING });
const success = books => ({ type: SUCCESS, books });
const fail = error => ({ type: FAIL, error });
// thunk 함수
export const getBooks = token => async dispatch => {
dispatch(start());
try {
await sleep(2000);
const res = await BookService.getBooks(token);
dispatch(success(res.data));
} catch (error) {
dispatch(fail(error));
}
};
// 리듀서
const books = (state = initialState, action) => {
switch (action.type) {
case PENDING:
return {
books: [],
loading: true,
error: null,
};
case SUCCESS:
return {
books: [...action.books],
loading: false,
error: null,
};
case FAIL:
return {
books: [],
loading: false,
error: action.error,
};
default:
return state;
}
};
export default books;
function sleep(ms) {
return new Promise(resolve => {
setTimeout(() => {
resolve();
}, ms);
});
}
// src/redux/modules/auth.js
import UserService from '../../services/UserService';
const PENDING = 'reactjs-books-review/auth/PENDING';
const SUCCESS = 'reactjs-books-review/auth/SUCCESS';
const FAIL = 'reactjs-books-review/auth/FAIL';
const initialState = {
token: null,
loading: false,
error: null,
};
// 액션 생성자 함수
const start = () => ({ type: PENDING });
const success = token => ({ type: SUCCESS, token });
const fail = error => ({ type: FAIL, error });
// thunk 함수
export const login = (email, password) => async dispatch => {
try {
dispatch(start());
const res = await UserService.login(email, password);
const { token } = res.data;
localStorage.setItem('token', token);
dispatch(success(token));
} catch (error) {
dispatch(fail(error));
}
};
export const logout = token => async dispatch => {
// 서버에 알려주기
try {
await UserService.logout(token);
} catch (error) {
console.log(error);
}
// 토큰 지우기
localStorage.removeItem('token');
// 리덕스 토큰 지우기
dispatch(success(null));
};
const auth = (state = initialState, action) => {
switch (action.type) {
case PENDING:
return {
...state,
loading: true,
error: null,
};
case SUCCESS:
return {
token: action.token,
loading: false,
error: null,
};
case FAIL:
return {
token: null,
loading: false,
error: action.error,
};
default:
return state;
}
};
export default auth;
// src/redux/modules/reducer.js
import { combineReducers } from 'redux';
import auth from './auth';
import books from './books';
const reducer = combineReducers({
auth,
books,
});
export default reducer;
// src/redux/create.js
import { createStore, applyMiddleware } from 'redux';
import reducer from './modules/reducer';
import { composeWithDevTools } from 'redux-devtools-extension';
import thunk from 'redux-thunk';
export default function create(token) {
const initialState = {
books: undefined,
auth: {
token,
loading: false,
error: null,
},
};
const store = createStore(
reducer,
initialState,
composeWithDevTools(applyMiddleware(thunk)),
);
return store;
}
// src/index.js
import React from 'react';
import ReactDOM from 'react-dom';
import 'antd/dist/antd.css';
import './index.css';
import App from './App';
import * as serviceWorker from './serviceWorker';
import create from './redux/create';
import { Provider } from 'react-redux';
const token = localStorage.getItem('token');
const store = create(token);
ReactDOM.render(
<Provider store={store}>
<App />
</Provider>,
document.getElementById('root'),
);
// If you want your app to work offline and load faster, you can change
// unregister() to register() below. Note this comes with some pitfalls.
// Learn more about service workers: https://bit.ly/CRA-PWA
serviceWorker.unregister();
// src/containers/BooksContainer.jsx
import { connect } from 'react-redux';
import Books from '../components/Books';
import { getBooks } from '../redux/modules/books';
const mapStateToProps = state => ({
token: state.auth.token,
books: state.books.books,
loading: state.books.loading,
error: state.books.error,
});
const mapDispatchToProps = dispatch => ({
getBooks: token => {
dispatch(getBooks(token));
},
});
export default connect(mapStateToProps, mapDispatchToProps)(Books);
// src/components/Books.jsx
import React from 'react';
import { useEffect } from 'react';
const Books = ({ token, books, loading, error, getBooks }) => {
useEffect(() => {
getBooks(token);
}, [token, getBooks]);
if (error !== null) {
return <div>에러다</div>;
}
return (
<>
{loading && <p>로딩 중...</p>}
<ul>
{books.map(book => (
<li key={book.bookId}>{book.title}</li>
))}
</ul>
</>
);
};
export default Books;
// src/containers/BooksContainer.jsx
import { connect } from 'react-redux';
import Books from '../components/Books';
import { getBooks } from '../redux/modules/books';
const mapStateToProps = state => ({
token: state.auth.token,
books: state.books.books,
loading: state.books.loading,
error: state.books.error,
});
const mapDispatchToProps = dispatch => ({
getBooks: token => {
dispatch(getBooks(token));
},
});
export default connect(mapStateToProps, mapDispatchToProps)(Books);
// src/containers/BooksContainer.jsx
import React, { useCallback } from 'react';
import { useSelector, useDispatch } from 'react-redux';
import Books from '../components/Books';
import { getBooks as getBooksAction } from '../redux/modules/books';
const BooksContainer = props => {
const token = useSelector(state => state.auth.token);
const { books, loading, error } = useSelector(state => state.books);
const dispatch = useDispatch();
const getBooks = useCallback(() => {
dispatch(getBooksAction(token));
}, [token, dispatch]); // token 을 보낼 필요 없다.
return (
<Books
{...props}
books={books}
loading={loading}
error={error}
getBooks={getBooks}
/>
);
};
export default BooksContainer;
// src/containers/BooksContainer.jsx
import React, { useCallback } from 'react';
import { useSelector, useDispatch } from 'react-redux';
import Books from '../components/Books';
import { getBooks as getBooksAction } from '../redux/modules/books';
const BooksContainer = props => {
const { books, loading, error } = useSelector(state => state.books);
const dispatch = useDispatch();
const getBooks = useCallback(() => {
dispatch(getBooksAction()); // token 을 thunk 안에서 처리
}, [dispatch]);
return (
<Books
{...props}
books={books}
loading={loading}
error={error}
getBooks={getBooks}
/>
);
};
export default BooksContainer;
// src/redux/modules/books.js
// thunk 함수
export const getBooks = () => async (dispatch, getState) => {
const state = getState();
const token = state.auth.token;
dispatch(start());
try {
await sleep(2000);
const res = await BookService.getBooks(token);
dispatch(success(res.data));
} catch (error) {
dispatch(fail(error));
}
};
npm install connected-react-router
// src/redux/modules/reducer.js
import { combineReducers } from 'redux';
import auth from './auth';
import books from './books';
import { connectRouter } from 'connected-react-router';
const reducer = history =>
combineReducers({
auth,
books,
router: connectRouter(history),
});
export default reducer;
// src/redux/create.js
import { createStore, applyMiddleware } from 'redux';
import reducer from './modules/reducer';
import { composeWithDevTools } from 'redux-devtools-extension';
import thunk from 'redux-thunk';
import { createBrowserHistory } from 'history';
import { routerMiddleware } from 'connected-react-router';
export const history = createBrowserHistory();
export default function create(token) {
const initialState = {
books: undefined,
auth: {
token,
loading: false,
error: null,
},
};
const store = createStore(
reducer(history),
initialState,
composeWithDevTools(applyMiddleware(thunk, routerMiddleware(history))),
);
return store;
}
import React from 'react';
import { Switch, Route } from 'react-router-dom';
import Home from './pages/Home';
import Signin from './pages/Signin';
import NotFound from './pages/NotFound';
import ErrorBoundary from 'react-error-boundary';
import { ConnectedRouter } from 'connected-react-router';
import { history } from './redux/create';
const ErrorFallbackComponent = ({ error }) => <div>{error.message}</div>;
const App = () => (
<ErrorBoundary FallbackComponent={ErrorFallbackComponent}>
<ConnectedRouter history={history}>
<Switch>
<Route exact path="/signin" component={Signin} />
<Route exact path="/" component={Home} />
<Route component={NotFound} />
</Switch>
</ConnectedRouter>
</ErrorBoundary>
);
export default App;
// src/redux/modules/auth.js
export const login = (email, password) => async dispatch => {
try {
dispatch(start());
const res = await UserService.login(email, password);
const { token } = res.data;
localStorage.setItem('token', token);
dispatch(success(token));
dispatch(push('/'));
} catch (error) {
dispatch(fail(error));
}
};
npm i redux-saga
미들웨어 입니다.
제너레이터 객체를 만들어 내는 제네레이터 생성 함수를 이용합니다.
리덕스 사가 미들웨어를 설정하고,
내가 만든 사가 함수를 등록한 후
사가 미들웨어를 실행합니다.
그리고 등록된 사가 함수를 실행할 액션을 디스패치하면 됩니다.
// src/redux/create.js
import { createStore, applyMiddleware } from 'redux';
import reducer from './modules/reducer';
import { composeWithDevTools } from 'redux-devtools-extension';
import thunk from 'redux-thunk';
import { createBrowserHistory } from 'history';
import { routerMiddleware } from 'connected-react-router';
import createSagaMiddleware from 'redux-saga'; // 1. import
export const history = createBrowserHistory();
const sagaMiddleware = createSagaMiddleware(); // 2. saga 미들웨어 생성
export default function create(token) {
const initialState = {
books: undefined,
auth: {
token,
loading: false,
error: null,
},
};
const store = createStore(
reducer(history),
initialState,
composeWithDevTools(
applyMiddleware(thunk, routerMiddleware(history), sagaMiddleware), // 3. 리덕스 미들웨어에 saga 미들웨어 추가
),
);
return store;
}
// src/redux/modules/books.js
import { delay, put, call } from 'redux-saga'; // 사가 이펙트 추가
// saga 함수
function* getBooksSaga(action) {
const token = action.payload.token;
yield put(start());
try {
yield delay(2000);
const res = yield call(BookService.getBooks, token);
yield put(success(res.data));
} catch (error) {
yield put(fail(error));
}
}
// src/redux/modules/books.js
import { delay, put, call, takeEvery } from 'redux-saga/effects'; // 사가 이펙트 추가
// saga 함수
function* getBooksSaga(action) {
const token = action.payload.token;
yield put(start());
try {
yield delay(2000);
const res = yield call(BookService.getBooks, token);
yield put(success(res.data));
} catch (error) {
yield put(fail(error));
}
}
// getBooksSaga 를 시작하는 액션 타입 정의
const START_SAGA = 'START_SAGA';
// getBooksSaga 를 시작하는 액션 생성 함수
export const startSaga = token => ({ type: START_SAGA, payload: { token } });
// saga 함수를 등록하는 saga
export function* booksSaga() {
yield takeEvery(START_SAGA, getBooksSaga);
}
// src/redux/modules/saga.js
import { all } from 'redux-saga/effects';
import { booksSaga } from './books';
export default function* rootSaga() {
yield all([booksSaga()]);
}
// src/redux/create.js
import { createStore, applyMiddleware } from 'redux';
import reducer from './modules/reducer';
import { composeWithDevTools } from 'redux-devtools-extension';
import thunk from 'redux-thunk';
import { createBrowserHistory } from 'history';
import { routerMiddleware } from 'connected-react-router';
import createSagaMiddleware from 'redux-saga';
import rootSaga from './modules/saga'; // 나의 사가 가져오기
export const history = createBrowserHistory();
const sagaMiddleware = createSagaMiddleware();
export default function create(token) {
const initialState = {
books: undefined,
auth: {
token,
loading: false,
error: null,
},
};
const store = createStore(
reducer(history),
initialState,
composeWithDevTools(
applyMiddleware(thunk, routerMiddleware(history), sagaMiddleware),
),
);
sagaMiddleware.run(rootSaga); // 나의 사가들을 실행
return store;
}
// src/containers/BooksContainer.jsx
import React, { useCallback } from 'react';
import { useSelector, useDispatch } from 'react-redux';
import Books from '../components/Books';
import { startSaga } from '../redux/modules/books';
const BooksContainer = props => {
const token = useSelector(state => state.auth.token);
const { books, loading, error } = useSelector(state => state.books);
const dispatch = useDispatch();
const getBooks = useCallback(() => {
dispatch(startSaga(token));
}, [token, dispatch]);
return (
<Books
{...props}
books={books}
loading={loading}
error={error}
getBooks={getBooks}
/>
);
};
export default BooksContainer;
// src/containers/BooksContainer.jsx
import React, { useCallback } from 'react';
import { useSelector, useDispatch } from 'react-redux';
import Books from '../components/Books';
import { startSaga } from '../redux/modules/books';
const BooksContainer = props => {
const { books, loading, error } = useSelector(state => state.books);
const dispatch = useDispatch();
const getBooks = useCallback(() => {
dispatch(startSaga());
}, [dispatch]);
return (
<Books
{...props}
books={books}
loading={loading}
error={error}
getBooks={getBooks}
/>
);
};
export default BooksContainer;
// src/redux/modules/books.js
import { delay, put, call, takeEvery, select } from 'redux-saga/effects'; // select 추가
// saga 함수
function* getBooksSaga() {
const token = yield select(state => state.auth.token); // 여기사 가져오기
yield put(start());
try {
yield delay(2000);
const res = yield call(BookService.getBooks, token);
yield put(success(res.data));
} catch (error) {
yield put(fail(error));
}
}
// src/redux/modules/books.js
import { delay, put, call, takeEvery, takeLatest, takeLeading, select } from 'redux-saga/effects';
// saga 함수를 등록하는 saga
export function* booksSaga() {
yield takeEvery(START_SAGA, getBooksSaga);
// yield takeLatest(START_SAGA, getBooksSaga);
// yield takeLeading(START_SAGA, getBooksSaga);
}
npm i redux-actions
// src/redux/modules/books.js
import { createAction } from 'redux-actions';
const start = createAction('START');
const success = createAction('SUCCESS', books => ({ books }));
const fail = createAction('FAIL');
console.log(start());
console.log(success(['book']));
console.log(fail(new Error()));
// src/redux/modules/books.js
import { createActions } from 'redux-actions';
const { start, success, fail } = createActions(
{
SUCCESS: books => ({ books }),
},
'START',
'FAIL',
{
prefix: 'reactjs-books-review/books',
},
);
console.log(start());
console.log(success(['book']));
console.log(fail(new Error()));
// src/redux/modules/books.js
import { handleActions } from 'redux-actions';
const books = handleActions(
{
START: () => ({
books: [],
loading: true,
error: null,
}),
SUCCESS: (state, action) => ({
books: action.payload.books,
loading: false,
error: null,
}),
FAIL: (state, action) => ({
books: [],
loading: false,
error: action.payload,
}),
},
initialState,
{
prefix: 'reactjs-books-review/books',
},
);
By Woongjae Lee
Fast Campus React Camp 9