Woongjae Lee
NHN Dooray - Frontend Team
Async Action with Redux
리덕스 미들웨어
redux-devtools
redux-thunk
redux-promise-middleware
service 분리
Lead Software Engineer @ProtoPie
Microsoft MVP
TypeScript Korea User Group Organizer
Marktube (Youtube)
이 웅재
git clone https://github.com/xid-mark/fds17-my-books.git
cd fds17-my-books-jsx
nvm use
npm ci
// src/redux/actions.js
export const LOGIN_START = 'LOGIN_START';
export const LOGIN_SUCCESS = 'LOGIN_SUCCESS';
export const LOGIN_FAIL = 'LOGIN_FAIL';
export const loginStartAction = () => ({ type: LOGIN_START });
export const loginSuccessAction = (token) => ({ type: LOGIN_SUCCESS, token });
export const loginFailAction = (error) => ({ type: LOGIN_FAIL, error });
export const BOOKS_START = 'BOOKS_START';
export const BOOKS_SUCCESS = 'BOOKS_SUCCESS';
export const BOOKS_FAIL = 'BOOKS_FAIL';
export const booksStartAction = () => ({ type: BOOKS_START });
export const booksSuccessAction = (books) => ({ type: BOOKS_SUCCESS, books });
export const booksFailAction = (error) => ({ type: BOOKS_FAIL, error });
// src/redux/books.js
import { BOOKS_START, BOOKS_SUCCESS, BOOKS_FAIL } from './actions';
const initialState = {
books: [],
loading: false,
error: null,
};
const books = (state = initialState, action) => {
if (action.type === BOOKS_START) {
return {
...state,
loading: true,
error: null,
};
} else if (action.type === BOOKS_SUCCESS) {
return {
books: action.books,
loading: false,
error: null,
};
} else if (action.type === BOOKS_FAIL) {
return {
...books,
loading: false,
error: action.error,
};
}
return state;
};
export default books;
// src/containers/BooksContainer.jsx
import React, { useCallback } from 'react';
import Books from '../components/Books';
import { useDispatch, useSelector } from 'react-redux';
import { booksStartAction, booksSuccessAction, booksFailAction } from '../redux/actions';
const BooksContainer = ({ token }) => {
// const token = useSelector((state) => state.auth.token);
const books = useSelector((state) => state.books.books);
const loading = useSelector((state) => state.books.loading);
const error = useSelector((state) => state.books.error);
const dispatch = useDispatch();
const booksStart = () => {
dispatch(booksStartAction());
};
const booksSuccess = (books) => {
dispatch(booksSuccessAction(books));
};
const booksFail = (error) => {
dispatch(booksFailAction(error));
};
return (
<Books
token={token}
books={books}
loading={loading}
error={error}
booksStart={booksStart}
booksSuccess={booksSuccess}
booksFail={booksFail}
/>
);
};
export default BooksContainer;
// src/components/Books.jsx
import React, { useEffect } from 'react';
import axios from 'axios';
import { withRouter } from 'react-router-dom';
import Book from './Book';
const Books = ({ token, books, loading, error, booksStart, booksSuccess, booksFail }) => {
useEffect(() => {
if (error === null) return;
}, [error]);
useEffect(() => {
async function getBooks() {
booksStart();
await sleep(1000);
try {
const res = await axios.get('https://api.marktube.tv/v1/book', {
headers: {
Authorization: `Bearer ${token}`,
},
});
booksSuccess(res.data);
} catch (error) {
booksFail(error);
}
}
getBooks();
}, [token, booksStart, booksSuccess, booksFail]);
return (
<>
{books.map((book) => (
<Book key={book.bookId} {...book} />
))}
</>
);
};
export default Books;
// src/pages/Home.jsx
import React from 'react';
import { Redirect } from 'react-router-dom';
import withToken from '../hocs/withToken';
import { Button } from 'antd';
import BooksContainer from '../containers/BooksContainer';
const Home = ({ token, history }) => {
console.log('Home', token);
if (token === null) {
return <Redirect to="/signin" />;
}
return (
<div>
<h1>Home</h1>
<Button onClick={click}>Logout</Button>
<BooksContainer token={token} />
</div>
);
function click() {
localStorage.removeItem('token');
history.push('/signin');
}
};
export default withToken(Home);
// src/containers/BooksContainer.jsx
import React, { useCallback } from 'react';
import Books from '../components/Books';
import { useDispatch, useSelector } from 'react-redux';
import { booksStartAction, booksSuccessAction, booksFailAction } from '../redux/actions';
const BooksContainer = ({ token }) => {
const dispatch = useDispatch();
const booksStart = useCallback(() => {
dispatch(booksStartAction());
}, [dispatch]);
const booksSuccess = useCallback(
(books) => {
dispatch(booksSuccessAction(books));
},
[dispatch],
);
const booksFail = useCallback(
(error) => {
dispatch(booksFailAction(error));
},
[dispatch],
);
return (...);
};
export default BooksContainer;
액션을 분리합니다.
Start
Success
Fail
... 등등
dispatch 를 할때 해줍니다.
당연히 리듀서는 동기적인 것 => Pure
dispatch 도 동기적인 것
// 액션 정의
export const BOOKS_START = 'BOOKS_START';
export const BOOKS_SUCCESS = 'BOOKS_SUCCESS';
export const BOOKS_FAIL = 'BOOKS_FAIL';
// 액션 생성자 함수
export const booksStartAction = () => ({ type: BOOKS_START });
export const booksSuccessAction = (books) => ({ type: BOOKS_SUCCESS, books });
export const booksFailAction = (error) => ({ type: BOOKS_FAIL, error });
// src/components/Books.jsx
import React, { useEffect } from 'react';
import axios from 'axios';
import { withRouter } from 'react-router-dom';
import Book from './Book';
const Books = ({ token, books, loading, error, booksStart, booksSuccess, booksFail }) => {
useEffect(() => {
if (error === null) return;
}, [error]);
useEffect(() => {
async function getBooks() {
booksStart();
await sleep(1000);
try {
const res = await axios.get('https://api.marktube.tv/v1/book', {
headers: {
Authorization: `Bearer ${token}`,
},
});
booksSuccess(res.data);
} catch (error) {
booksFail(error);
}
}
getBooks();
}, [token, booksStart, booksSuccess, booksFail]);
return (
<>
{books.map((book) => (
<Book key={book.bookId} {...book} />
))}
</>
);
};
export default Books;
import React from 'react';
import Books from '../components/Books';
import { useDispatch, useSelector } from 'react-redux';
import { booksStartAction, booksSuccessAction, booksFailAction } from '../redux/actions';
import axios from 'axios';
const BooksContainer = ({ token }) => {
// const token = useSelector((state) => state.auth.token);
const books = useSelector((state) => state.books.books);
const loading = useSelector((state) => state.books.loading);
const error = useSelector((state) => state.books.error);
const dispatch = useDispatch();
const booksStart = dispatch(booksStartAction());
const booksSuccess = (books) => dispatch(booksSuccessAction(books));
const booksFail = (error) => dispatch(booksFailAction(error));
async function getBooks() {
booksStart();
await sleep(1000);
try {
const res = await axios.get('https://api.marktube.tv/v1/book', {
headers: {
Authorization: `Bearer ${token}`,
},
});
booksSuccess(res.data);
} catch (error) {
booksFail(error);
}
}
return (
<Books books={books} loading={loading} error={error} getBooks={getBooks} />
);
};
export default BooksContainer;
// src/components/Books.jsx
import React, { useEffect } from 'react';
import Book from './Book';
const Books = ({ books, loading, error, getBooks }) => {
useEffect(() => {
if (error === null) return;
}, [error]);
useEffect(() => {
getBooks(); // 컨테이너로 로직을 옮겼음.
}, [getBooks]);
return (
<>
{books.map((book) => (
<Book key={book.bookId} {...book} />
))}
</>
);
};
export default Books;
미들웨어가 "디스패치" 의 앞뒤에 코드를 추가할수 있게 해줍니다.
미들웨어가 여러개면 미들웨어가 "순차적으로" 실행됩니다.
두 단계가 있습니다.
스토어를 만들때, 미들웨어를 설정하는 부분
{createStore, applyMiddleware} from redux
디스패치가 호출될때 실제로 미들웨어를 통과하는 부분
dispatch 메소드를 통해 store로 가고 있는 액션을 가로채는 코드
function middleware1(store) {
return next => {
console.log('middleware1', 1);
return action => {
console.log('middleware1', 2);
const returnValue = next(action);
console.log('middleware1', 3);
return returnValue;
};
};
}
function middleware2(store) {
return next => {
console.log('middleware2', 1);
return action => {
console.log('middleware2', 2);
const returnValue = next(action);
console.log('middleware2', 3);
return returnValue;
};
};
}
import { createStore, applyMiddleware } from 'redux';
function middleware1(store) {...}
function middleware2(store) {...}
const store = createStore(reducer, applyMiddleware(middleware1, middleware2));
function middleware1(store) {
return next => {
console.log('middleware1', 1, store.getState());
return action => {
console.log('middleware1', 2, store.getState());
const returnValue = next(action);
console.log('middleware1', 3, store.getState());
return returnValue;
};
};
}
npm install -D redux-devtools-extension
import { createStore, applyMiddleware } from "redux";
import reducers from "./reducers";
import { composeWithDevTools } from "redux-devtools-extension";
const store = createStore(reducers, composeWithDevTools(applyMiddleware()));
export default store;
npm i redux-thunk
import { createStore, applyMiddleware } from "redux";
import reducers from "./reducers";
import { composeWithDevTools } from "redux-devtools-extension";
import thunk from "redux-thunk"; // import
const store = createStore(
reducers,
composeWithDevTools(applyMiddleware(thunk)) // 미들웨어 설정
);
export default store;
const mapDispatchToProps = dispatch => ({
requestBooks: async token => {
dispatch(startLoading());
dispatch(clearError());
try {
const res = await axios.get("https://api.marktube.tv/v1/book", {
headers: {
Authorization: `Bearer ${token}`
}
});
dispatch(setBooks(res.data));
dispatch(endLoading());
} catch (error) {
console.log(error);
dispatch(setError(error));
dispatch(endLoading());
}
}
});
// BooksContainer.jsx
const mapDispatchToProps = dispatch => ({
requestBooks: async token => {...},
requestBooksThunk: token => {
dispatch(setBooksThunk(token));
}
});
// actions/index.js
export const setBooksThunk = token => async dispatch => {
dispatch(startLoading());
dispatch(clearError());
try {
const res = await axios.get("https://api.marktube.tv/v1/book", {
headers: {
Authorization: `Bearer ${token}`
}
});
dispatch(setBooks(res.data));
dispatch(endLoading());
} catch (error) {
console.log(error);
dispatch(setError(error));
dispatch(endLoading());
}
};
npm i redux-promise-middleware
import { createStore, applyMiddleware } from "redux";
import reducers from "./reducers";
import { composeWithDevTools } from "redux-devtools-extension";
import thunk from "redux-thunk";
import promise from "redux-promise-middleware"; // import
const store = createStore(
reducers,
composeWithDevTools(applyMiddleware(thunk, promise)) // 미들웨어 설정
);
export default store;
// actions/index.js
export const setBooksPromise = token => ({
type: BOOKS,
payload: axios.get("https://api.marktube.tv/v1/book", {
headers: {
Authorization: `Bearer ${token}`
}
})
});
// actions/index.js
export const BOOKS = 'BOOKS';
export const BOOKS_PENDING = 'BOOKS_PENDING';
export const BOOKS_FULFILLED = 'BOOKS_FULFILLED';
export const BOOKS_REJECTED = 'BOOKS_REJECTED';
// reducers/loading.js
export default function loading(state = initialState, action) {
switch (action.type) {
case BOOKS_PENDING:
return true;
case BOOKS_FULFILLED:
return false;
case BOOKS_REJECTED:
return false;
default:
return state;
}
}
{
type: 'BOOKS_PENDING'
}
{
type: 'BOOKS_FULFILLED'
payload: {
...
}
}
{
type: 'BOOKS_REJECTED'
error: true,
payload: {
...
}
}
// reducers/books.js
const books = (state = initialState, action) => {
switch (action.type) {
case BOOKS_FULFILLED: {
return [...action.payload.data]
}
...
}
// src/services/BookService.jsx
import axios from 'axios';
import { BookReqType } from '../types';
const BOOK_API_URL = 'https://api.marktube.tv/v1/book';
export default class BookService {
static async getBooks(token: string) {
return axios.get(BOOK_API_URL, {
headers: {
Authorization: `Bearer ${token}`,
},
});
}
static async getBook(token: string, bookId: string) {
return axios.get(`${BOOK_API_URL}/${bookId}`, {
headers: {
Authorization: `Bearer ${token}`,
},
});
}
static async addBook(token: string, book: BookReqType) {
return axios.post(BOOK_API_URL, book, {
headers: {
Authorization: `Bearer ${token}`,
},
});
}
static async deleteBook(token: string, bookId: string) {
return axios.delete(`${BOOK_API_URL}/${bookId}`, {
headers: {
Authorization: `Bearer ${token}`,
},
});
}
static async editBook(token: string, bookId: string, book: BookReqType) {
return axios.patch(`${BOOK_API_URL}/${bookId}`, book, {
headers: {
Authorization: `Bearer ${token}`,
},
});
}
}
// src/services/UserService.jsx
import axios from 'axios';
const USER_API_URL = 'https://api.marktube.tv/v1/me';
export default class UserService {
static login(email: string, password: string) {
return axios.post(USER_API_URL, {
email,
password,
});
}
static logout(token: string) {
return axios.delete(USER_API_URL, {
headers: {
Authorization: `Bearer ${token}`,
},
});
}
}
By Woongjae Lee
Fast Campus Frontend Developer School 17th