Redux Advanced (2)

 

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)

Mark Lee

git clone -b fc-school https://github.com/2woongjae/reactjs-books-review.git

cd reactjs-books-review

nvm use

npm ci

src/redux

- create.js

 

src/redux/modules

  - 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;

Connect with Hooks

// 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));
  }
};

react-router 와 redux 함께 쓰기

https://github.com/supasate/connected-react-router

npm install connected-react-router

reducer 에 router 라는 state 를 combine

// 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;

store 에 routerMiddleware 를 추가

// 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;
}

Router => ConnectedRouter

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;

history.push() 대신 dispatch(push())

// 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()]);
}

나의 여러 사가 모듈을 합친 rootSaga 만들기

// 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;
}

rootSaga 를 사가 미들웨어로 실행

// 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;

select 이펙트 활용하기 (1)

// 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));
  }
}

select 이펙트 활용하기 (2)

// 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);
}

takeEvery, takeLatest, takeLeading

npm i redux-actions
  • createAction, createActions

  • handleAction, handleActions

  • combineActions

// 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()));

createAction

// 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()));

createActions

// 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',
  },
);

handleActions