Woongjae Lee
Daangn - Frontend Core Team ex) NHN Dooray - Frontend Team Leader ex) ProtoPie - Studio Team
Lead Software Engineer @ProtoPie
Microsoft MVP
TypeScript Korea User Group Organizer
Marktube (Youtube)
이 웅재
1-1) Redux 개요 1-2) Action - 액션
1-3) Reducers - 리듀서 1-4) createStore
1-5) combineReducers 1-6) Redux 를 React 에 연결
단일 스토어다!
[만들기] 단일 스토어 사용 준비하기
import redux
액션을 정의하고,
액션을 사용하는, 리듀서를 만들고,
리듀서들을 합친다.
최종 합쳐진 리듀서를 인자로, 단일 스토어를 만든다.
[사용하기] 준비한 스토어를 리액트 컴포넌트에서 사용하기
import react-redux
connect 함수를 이용해서 컴포넌트에 연결
npx create-react-app redux-start
cd redux-start
npm i redux
function 액션생성자(...args) { return 액션; }
액션의 타입을 정의하여 변수로 빼는 단계
강제는 아닙니다. (그러므로 안해도 됩니다.)
그냥 타입을 문자열로 넣기에는 실수를 유발할 가능성이 큽니다.
미리 정의한 변수를 사용하면, 스펠링에 주의를 덜 기울여도 됩니다.
액션 객체를 만들어 내는 함수를 만드는 단계
하나의 액션 객체를 만들기 위해 하나의 함수를 만들어냅니다.
액션의 타입은 미리 정의한 타입 변수로 부터 가져와서 사용합니다.
// actions.js
// 액션의 type 정의
// 액션의 타입 => 액션 생성자 이름
// ADD_TODO => addTodo
export const ADD_TODO = 'ADD_TODO';
// 액션 생산자
// 액션의 타입은 미리 정의한 타입으로 부터 가져와서 사용하며,
// 사용자가 인자로 주지 않습니다.
export function addTodo(text) {
return { type: ADD_TODO, text }; // { type: ADD_TODO, text: text }
}
function 리듀서(previousState, action) {
return newState;
}
// reducers.js
import { ADD_TODO } from './actions';
export function todoApp(previousState, action) {
if (previousState === undefined) {
return [];
}
if (action.type === ADD_TODO) {
return [...previousState, { text: action.text }];
}
return previousState;
}
redux 로 부터 import
const store = createStore(리듀서);
// store.js
import { todoApp } from './reducers';
import { createStore } from 'redux';
import { addTodo } from './actions';
const store = createStore(todoApp);
console.log(store);
console.log(store.getState());
setTimeout(() => {
store.dispatch(addTodo('hello'));
}, 1000);
export default store;
// index.js
import React from 'react';
import ReactDOM from 'react-dom';
import './index.css';
import App from './App';
import * as serviceWorker from './serviceWorker';
import store from './store';
store.subscribe(() => {
const state = store.getState();
console.log('store changed', state);
});
ReactDOM.render(<App />, document.getElementById('root'));
serviceWorker.unregister();
action 을 정의하고, action 생성자를 만들고, reducer 를 수정
// actions.js
// 액션의 type 정의
// 액션의 타입 => 액션 생성자 이름
// ADD_TODO => addTodo
export const ADD_TODO = 'ADD_TODO';
export const COMPLETE_TODO = 'COMPLETE_TODO';
// 액션 생산자
// 액션의 타입은 미리 정의한 타입으로 부터 가져와서 사용하며,
// 사용자가 인자로 주지 않습니다.
export function addTodo(text) {
return { type: ADD_TODO, text }; // { type: ADD_TODO, text: text }
}
// actions.js
// 액션의 type 정의
// 액션의 타입 => 액션 생성자 이름
// ADD_TODO => addTodo
export const ADD_TODO = 'ADD_TODO';
export const COMPLETE_TODO = 'COMPLETE_TODO';
// 액션 생산자
// 액션의 타입은 미리 정의한 타입으로 부터 가져와서 사용하며,
// 사용자가 인자로 주지 않습니다.
export function addTodo(text) {
return { type: ADD_TODO, text }; // { type: ADD_TODO, text: text }
}
export function completeTodo(index) {
return { type: COMPLETE_TODO, index }; // { type: COMPLETE_TODO, index: index}
}
import { ADD_TODO, COMPLETE_TODO } from './actions';
export function todoApp(previousState, action) {
if (previousState === undefined) {
return [];
}
if (action.type === ADD_TODO) {
return [...previousState, { text: action.text, completed: false }];
}
if (action.type === COMPLETE_TODO) {
const newState = [];
for (let i = 0; i < previousState.length; i++) {
newState.push(
i === action.index
? { ...previousState[i], completed: true }
: { ...previousState[i] },
);
}
return newState;
}
return previousState;
}
// store.js
import { todoApp } from './reducers';
import { createStore } from 'redux';
import { addTodo, completeTodo } from './actions';
const store = createStore(todoApp);
console.log(store);
console.log(store.getState());
setTimeout(() => {
store.dispatch(addTodo('hello'));
setTimeout(() => {
store.dispatch(completeTodo(0));
}, 1000);
}, 1000);
export default store;
리듀서를 크게 만들고, state 를 변경하는 모든 로직을 담을 수도 있습니다.
리듀서를 분할해서 만들고, 합치는 방법을 사용할 수 있습니다.
todos 만 변경하는 액션들을 처리하는 A 라는 리듀서 함수를 만들고,
filter 만을 변경하는 액션들을 처리하는 B 라는 리듀서 함수를 만들고,
A 와 B 를 합침.
[
{
text: 'Hello',
completed: false
}
]
{
todos: [
{
text: 'Hello',
completed: false
}
],
filter: 'SHOW_ALL'
}
import { ADD_TODO, COMPLETE_TODO } from './actions';
export function todoApp(previousState, action) {
if (previousState === undefined) {
return { todos: [], filter: 'SHOW_ALL' };
}
if (action.type === ADD_TODO) {
return {
todos: [...previousState.todos, { text: action.text, completed: false }],
filter: previousState.filter,
};
}
if (action.type === COMPLETE_TODO) {
const todos = [];
for (let i = 0; i < previousState.todos.length; i++) {
todos.push(
i === action.index
? { ...previousState.todos[i], completed: true }
: { ...previousState.todos[i] },
);
}
return { todos, filter: previousState.filter };
}
return previousState;
}
export function todos(previousState, action) {
if (previousState === undefined) {
return [];
}
if (action.type === ADD_TODO) {
return [...previousState.todos, { text: action.text, completed: false }];
}
if (action.type === COMPLETE_TODO) {
const newState = [];
for (let i = 0; i < previousState.length; i++) {
newState.push(
i === action.index
? { ...previousState[i], completed: true }
: { ...previousState[i] },
);
}
return newState;
}
return previousState;
}
export function filter(previousState, action) {
if (previousState === undefined) {
return 'SHOW_ALL';
}
return previousState;
}
export function todoApp(previousState = {}, action) {
return {
todos: todos(previousState.todos, action),
filter: filter(previousState.filter, action),
};
}
redux 로 부터 import
import { combineReducers } from 'redux';
const todoApp = combineReducers({
todos,
filter,
});
// App.js
import React, { useState, useEffect } from 'react';
import './App.css';
import { addTodo } from './actions';
function App({ store }) {
const [state, setState] = useState(store.getState());
useEffect(() => {
const unsubscribe = store.subscribe(() => {
setState(store.getState());
});
return () => {
unsubscribe();
};
});
return (
<div className="App">
<header className="App-header">
<p>{JSON.stringify(state)}</p>
<button
onClick={() => {
store.dispatch(addTodo('Hello'));
}}
>
추가
</button>
</header>
</div>
);
}
export default App;
import React from 'react';
const ReduxContext = React.createContext();
export default ReduxContext;
import React from 'react';
import ReactDOM from 'react-dom';
import './index.css';
import App from './App2';
import * as serviceWorker from './serviceWorker';
import store from './store';
import ReduxContext from './context';
ReactDOM.render(
<ReduxContext.Provider value={store}>
<App />
</ReduxContext.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();
import React, { useContext } from 'react';
import './App.css';
import { addTodo } from './actions';
import ReduxContext from './context';
import Button from './Button';
class App extends React.Component {
static contextType = ReduxContext;
_unsubscribe;
state = this.context.getState();
componentDidMount() {
this._unsubscribe = this.context.subscribe(() => {
this.setState(this.context.getState());
});
}
componentWillUnmount() {
this._unsubscribe();
}
render() {
return (
<div className="App">
<header className="App-header">
<p>{JSON.stringify(this.state)}</p>
<Button />
</header>
</div>
);
}
}
export default App;
import React, { useContext } from 'react';
import { addTodo } from './actions';
import ReduxContext from './context';
export default function Button() {
const store = useContext(ReduxContext);
return (
<button
onClick={() => {
store.dispatch(addTodo('Hello'));
}}
>
추가
</button>
);
}
npm i react-redux
// index.js
import React from 'react';
import ReactDOM from 'react-dom';
import './index.css';
import App from './App3';
import * as serviceWorker from './serviceWorker';
import store from './store';
import { Provider } from 'react-redux';
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();
import React, { useContext, useEffect, useState } from 'react';
import { ReactReduxContext } from 'react-redux';
import './App.css';
import { addTodo } from './actions';
import Button from './Button';
class App extends React.Component {
render() {
console.log(this.props);
return (
<div className="App">
<header className="App-header">
<p>{JSON.stringify(this.props.todos)}</p>
<Button add={this.props.add} />
</header>
</div>
);
}
}
function AppContainer(props) {
const { store } = useContext(ReactReduxContext);
const [state, setState] = useState(store.getState());
function add(text, dispatch) {
console.log(text, dispatch);
dispatch(addTodo(text));
}
useEffect(() => {
const _unsubscribe = store.subscribe(() => {
setState(store.getState());
});
return () => {
_unsubscribe();
};
});
return (
<App
{...props}
todos={state.todos}
add={text => add(text, store.dispatch)}
/>
);
}
export default AppContainer;
import React from 'react';
export default function Button({ add }) {
return <button onClick={() => add('hello')}>추가</button>;
}
import React from 'react';
import './App.css';
import { addTodo } from './actions';
import { connect } from 'react-redux';
import Button from './Button';
class App extends React.Component {
render() {
return (
<div className="App">
<header className="App-header">
<p>{JSON.stringify(this.props.todos)}</p>
<Button add={this.props.add} />
</header>
</div>
);
}
}
const mapStateToProps = state => {
return { todos: state.todos };
};
const mapDispatchToProps = dispatch => {
return {
add: text => {
dispatch(addTodo(text));
},
};
};
const AppContainer = connect(
mapStateToProps,
mapDispatchToProps,
)(App);
export default AppContainer;
import React from 'react';
export default function Button({ add }) {
return <button onClick={() => add('hello')}>추가</button>;
}
const mapStateToProps = state => {
return { todos: state.todos };
};
const mapDispatchToProps = dispatch => {
return {
add: text => {
dispatch(addTodo(text));
},
};
};
2-1) Async Action with Redux
2-2) 리덕스 미들웨어
2-3) redux-devtools
2-4) redux-thunk
2-5) redux-promise-middleware
액션을 분리합니다.
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;
};
};
}
https://github.com/zalmoxisus/redux-devtools-extension
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;
https://github.com/reduxjs/redux-thunk
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());
}
};
https://pburtchaell.gitbook.io/redux-promise-middleware
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]
}
...
}
3-1) Ducks Pattern
3-2) react-router-dom 과 redux 함께 쓰기
3-3) redux-saga
3-4) redux-actions
https://github.com/erikras/ducks-modular-redux
- 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;
https://github.com/supasate/connected-react-router
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));
}
};
https://redux-saga.js.org
미들웨어 입니다.
제너레이터 객체를 만들어 내는 제네레이터 생성 함수를 이용합니다.
리덕스 사가 미들웨어를 설정하고,
내가 만든 사가 함수를 등록한 후
사가 미들웨어를 실행합니다.
그리고 등록된 사가 함수를 실행할 액션을 디스패치하면 됩니다.
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);
}
https://github.com/redux-utilities/redux-actions
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
리덕스 2021
Daangn - Frontend Core Team ex) NHN Dooray - Frontend Team Leader ex) ProtoPie - Studio Team