Redux Advanced (1)

 

Async Action with Redux

리덕스 미들웨어

redux-devtools

redux-thunk

redux-promise-middleware

service 분리

Software Engineer | Studio XID, Inc.

Microsoft MVP

TypeScript Korea User Group Organizer

Electron Korea User Group Organizer

Marktube (Youtube)

Mark Lee

git clone -b week5 https://github.com/2woongjae/my-books-jsx.git

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

function sleep(ms) {
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve();
    }, ms);
  });
}

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;

Async Action with Redux

비동기 작업을 어디서 하느냐 ? 가 젤 중요

  • 액션을 분리합니다.

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

function sleep(ms) {
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve();
    }, ms);
  });
}

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

function sleep(ms) {
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve();
    }, ms);
  });
}

export default BooksContainer;

비동기 처리가 컴테이너에 있는 경우

Books.jsx

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

applyMiddleware(함수1, 함수2, ...)

import { createStore, applyMiddleware } from 'redux';

function middleware1(store) {...}

function middleware2(store) {...}

const store = createStore(reducer, applyMiddleware(middleware1, middleware2));

middleware 에서 store 접근

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

composeWithDevTools

import { createStore, applyMiddleware } from "redux";
import reducers from "./reducers";
import { composeWithDevTools } from "redux-devtools-extension";

const store = createStore(reducers, composeWithDevTools(applyMiddleware()));

export default store;

redux-thunk

  • 리덕스 미들웨어

  • 리덕스를 만든 사람이 만들었음. (Dan)

  • 리덕스에서 비동기 처리를 위한 라이브러리

  • 액션 생성자를 활용하여 비동기 처리

  • 액션 생성자가 액션을 리턴하지 않고, 함수를 리턴함.

npm i redux-thunk

import thunk from '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;

Before Using thunk

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

Use thunk

// 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 promise from '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;

payload 가 Promise

// actions/index.js
export const setBooksPromise = token => ({
  type: BOOKS,
  payload: axios.get("https://api.marktube.tv/v1/book", {
    headers: {
      Authorization: `Bearer ${token}`
    }
  })
});

액션의 type 에 접미사를 붙인 액션을 자동 생성하고 자동으로 dispatch 시킴

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

payload 로 들어오는 데이터를 활용하여 표현

{
  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]
    }

  ...
}

Service 분리

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