22_리덕스 미들웨어
FrontEnd/React

22_리덕스 미들웨어

728x90

리덕스 미들웨어를 쓰지 않는건 리덕스를 쓸 이유가 없다는 것과 같다!

 

출처 : https://react.vlpt.us/redux-middleware/

액션이 일어나고 리듀서가 일어나기 전에 코드를 실행하게 하거나 액션을 수정하거나 또다른 액션을 만들어서 dispatch를 한더던지 등등의 기능을 쓸 수 있다.

 

주로 API요청과 같은 비동기 작업을 처리할때 굉장히 유용하게 쓸 수 있다.

 

먼저 리액트 프로젝트를 하나 만든 후에

 

yarn add redux react-redux

redux라이브러리와 react-redux라이브러리를 설치해 준다.

 

그 이후 간단한 리덕스 구조를 만들어주자.

저번 글에서 했던 카운터 예제를 다시한번 구현해보자.

 

다음과 같은 파일구조를 만들어주고

 

//counter.js
const INCREASE = 'INCREASE';  //action type
const DECREASE = 'DECREASE';

export const increase = () => ({ type : INCREASE}); //action생성함수
export const decrease = () => ({ type : DECREASE});

const initialState = 0; //초기상태


export default function counter (state = initialState,action) { //리듀서
    switch (action.type) {
        case INCREASE:
            return state +1;
        case DECREASE:
            return state - 1;
        default:
            return state;
    }
}

counter모듈안에 리덕스에 필요한 액션함수, 액션생성함수, 초기상태, 리듀서를 넣어준다.

 

//modules/index.js
import { combineReducers } from "redux"; //root리듀서를 만들기 위해
import counter from "./counter";

const rootReducer = combineReducers({counter});

export default rootReducer;

index에는 root리듀서를 만들어주어서 counter를 연동하여주고

 

//index.js
import React from 'react';
import ReactDOM from 'react-dom';
import './index.css';
import App from './App';
import reportWebVitals from './reportWebVitals';
import {Provider } from 'redux';   //리듀서 사용을 위해
import { createStore } from 'redux';
import rootReducer from './modules'

const store = createStore(rootReducer);

ReactDOM.render(
  <Provider store ={store}>
    <React.StrictMode>
      <App />
    </React.StrictMode>
  </Provider>,
  document.getElementById('root')
);

// If you want to start measuring performance in your app, pass a function
// to log results (for example: reportWebVitals(console.log))
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
reportWebVitals();

src디렉토리의 index파일 내의 App을 Provider로 감싸주면 된다. store를 하나 생성하는것도 필요하다. 저번글에서 말했듯이 한 프로젝트에는 한 store만 존재해야 한다.

 

그 다음 componets와 containers로 구분해서 시각부분을 보여줄 부분과 기능면을 담당할 부분을 분리해준 뒤에 코드를 각각 작성하여 준다.

 

//components/Counter.js

function Counter({ number, onIncrease, onDecrease}) {
    return (
        <div>
            <h1>{number}</h1>
            <button onClick={onIncrease}>+1</button>
            <button onClick={onDecrease}>-1</button>
        </div>
    );
}

export default Counter;
//CounterContainers.js

import React from 'react';
import Counter from '../components/Counter';
import {useSelector, useDispatch } from 'react-redux';
import { decrease, increase } from '../modules/counter';


function CounterContainer() {
    const number = useSelector(state => state.counter); //초기상태를 숫자로 두었음
    const dispatch = useDispatch();

    const onIncrease = () => {
        dispatch(increase());
    };

    const onDecrease = () =>{
        dispatch(decrease());
    };

    return (
        <Counter number={number} onIncrease={onIncrease} onDecrease={onDecrease}/>
    );
    
}


export default CounterContainer;

 

 

리덕스 미들웨어로 준비해 보자.

 

const middleware = store => next => action =>{

}

미들웨어는 다음과 같은 구조를 가지게 된다.

 

액션이 발생되면 미들웨어를 거치게 된다. 이때 다음 미들웨어나 리듀서로 전달하기 위해서 next를 꼭 거쳐야 한다. 만약 next를 거치지 않는다면 액션은 실행되지 않게 되는것이다.

 

next(action)꼴의 형태로 실행되게 되고, 만약 미들웨어상태에서 store.dispatch가 일어나면 위 그림처럼 다시 돌아오게 되는 것이다.

 

 

middleware를 그럼 직접 작성하여 보자.

//myLogger.js
const myLogger = store => next => action => {
    console.log(action);
    console.log('\t', store.getState())//action이 리듀서에서 처리되기 전 상태를 출력
    const result = next(action); //action을 다음 미들웨어나 리듀서로 전달
    console.log('\t', store.getState())//action이 리듀서에서 처리된 다음상태를 출력

    return result; // container에서 dispatch된 결과값
}

export default myLogger;

생각보다 간단하다.

 

action을 받아온 후에 하고싶은 작업들을 넣고, next를 호출해서 다음 미들웨어나 리듀서로 action을 전달한 후에, 리듀서 과정이 끝난후에 conatiner에서 dispatch된 결과값을 반환하게 되는 것이다.

 

즉, 이전에 리덕스를 사용했던 과정중에 중간과정을 하나 만든역할이다!

 

 

이번엔 redux-logger 라이브러리를 활용해 보자.

 

yarn add redux-logger

역시 먼저 라이브러리를 설치해준다.

 

import logger from 'redux-logger';


const store = createStore(rootReducer, applyMiddleware(myLogger,logger));

다음과 같이 추가해주고 두번째로 logger을 추가해 주면

 

mylogger가 실행되고 logger가 실행됨을 알 수 있다. 이는 mylogger에서 next()를 통해 logger로 넘겨주었기 때문이다.

 

자, 이제 mylogger는 없애주자

 

 

 

미들웨어와 Devtools를 함께 사용할 수도 있다.

yarn add redux-devtools-extension

먼저 패키지를 설치해준다

 

import {composeWithDevTools} from 'redux-devtools-extension';


const store = createStore(
  rootReducer, 
  composeWithDevTools(applyMiddleware(logger)));
//미들웨어 적용

다음 composeWithDevTools로 감싸주기만 하면 적용된다.

 

개발자 도구에 Redux 칸이 생긴걸 확인할 수 있다.

 

 

 

 

redux-thunk

정말 자주 사용되는 라이브러리이다. 액션객체가 아닌 함수를 디스패치 할 수 있게 해준다.

 

const thunk = store => next => action =>
  typeof action === 'function'
    ? action(store.dispatch, store.getState)
    : next(action)

이 라이브러리는 위 코드와 굉장히 유사하다. 즉, 액션객체가 아닌 함수가 들어오면 함수를 디스패치하게 설정되어 있다.

 

const getComments = () => (dispatch, getState) => {
  // 이 안에서는 액션을 dispatch 할 수도 있고
  // getState를 사용하여 현재 상태도 조회 할 수 있습니다.
  const id = getState().post.activeId;

  // 요청이 시작했음을 알리는 액션
  dispatch({ type: 'GET_COMMENTS' });

  // 댓글을 조회하는 프로미스를 반환하는 getComments 가 있다고 가정해봅시다.
  api
    .getComments(id) // 요청을 하고
    .then(comments => dispatch({ type: 'GET_COMMENTS_SUCCESS', id, comments })) // 성공시
    .catch(e => dispatch({ type: 'GET_COMMENTS_ERROR', error: e })); // 실패시
};

위 코드처럼, thunk를 사용하면 함수내에서 액션을 dispatch하거나 상태 조회도 할수 있는 등 다양한 작업을 할 수 있게 해준다. 

 

컴포넌트에서 dispatch(getComments()); 로 불러서 실행할 수 있다.

 

thunk를 직접 사용해보려면 우선 라이브러리를 설치해줘야 한다.

yarn add redux-thunk

 

import ReduxThunk from 'redux-thunk';


const store = createStore(
  rootReducer, 
  composeWithDevTools(applyMiddleware(ReduxThunk, logger)));//미들웨어 적용

그 다음, ReduxThunk를 불러와서 미들웨어에 추가해준다.

 

그 후 이전에 작성했던 counter예제를 1초후에 작동하도록 increaseAsyc,decreaseAsync함수를 추가해준다.

 

더보기
//counter.js
const INCREASE = 'INCREASE';  //action type
const DECREASE = 'DECREASE';

export const increase = () => ({ type : INCREASE}); //action생성함수
export const decrease = () => ({ type : DECREASE});

export const increaseAsync = () => (dispatch) => {
    setTimeout(() => {
        dispatch(increase());
    },1000)
}

export const decreaseAsync = () => (dispatch) => {
    setTimeout(() => {
        dispatch(decrease());
    },1000)
}

const initialState = 0; //초기상태


export default function counter (state = initialState,action) { //리듀서
    switch (action.type) {
        case INCREASE:
            return state +1;
        case DECREASE:
            return state - 1;
        default:
            return state;
    }
}

그 다음 CounterContainers에서 dispatch하던 내역을 바꾸어주면된다.

 

function CounterContainer() {
    const number = useSelector(state => state.counter); //초기상태를 숫자로 두었음
    const dispatch = useDispatch();

    const onIncrease = () => {
        dispatch(increaseAsync());
    };

    const onDecrease = () =>{
        dispatch(decreaseAsync());
    };

    return (
        <Counter number={number} onIncrease={onIncrease} onDecrease={onDecrease}/>
    );
    
}

이러면 카운터 예제들이 버튼을 누르고 1초 후에 동작하는것을 확인할 수 있다.

 

 

 

 

redux-thunk를 사용하면 Promise를 다루기 용이하다.

 

먼저 src파일내에 가상의 api파일을 하나 만들어보자.

 

const sleep = n => new Promise(resolve => setTimeout(resolve,n))

// {id,title, body}

const posts =[
    {
        id : 1,
        title : '정민규의 나이',
        body : '25살'
    },
    {
        id : 2,
        title : '정민규의 성별',
        body : '남자'
    },
    {
        id : 3,
        title : '정민규의 최애메뉴',
        body : '치킨'
    },
];

export const getPosts = async () => {
    await sleep(500);
    return posts;
}

export const getPostById = async (id) => {
    await sleep(500);
    return posts.find(post => post.id === id)
}

다음과 같은 내용과 그 배열에서 값을 가져올수 있는 함수들을 구현해 보았다.

 

그다음 모듈폴더내에 포스트를 가져오는 posts.js를 하나 만든다.

 

더보기
//.modules/posts.js

import * as postsAPI from '../api/posts';//postsAPI.getpost등으로 사용가능하게

/*액션*/
const GET_POSTS = 'GET_POSTS';
const GET_POSTS_SUCCESS = 'GET_POSTS_SUCCESS';
const GET_POSTS_ERROR = 'GET_POSTS_ERROR';

const GET_POST = 'GET_POST';
const GET_POST_SUCCESS = 'GET_POST_SUCCESS';
const GET_POST_ERROR = 'GET_POST_ERROR';  

/*thunk */
export const getPosts = () => async dispatch => {
    //요청 시작
    dispatch({type : GET_POSTS });

    //API호출
    try{
        const posts = await postsAPI.getPosts();
        dispatch({
            type : GET_POSTS_SUCCESS,
            posts
        });
    } catch (e) {
        dispatch({
            type : GET_POSTS_ERROR,
            error : e
        })
    }
}

export const getPost = (id) => async dispatch => {
    //요청 시작
    dispatch({type : GET_POST });

    //API호출
    try{
        const post = await postsAPI.getPost(id);
        dispatch({
            type : GET_POST_SUCCESS,
            post
        });
    } catch (e) {
        dispatch({
            type : GET_POST_ERROR,
            error : e
        })
    }
}

이번에는 액션생성함수를 굳이 안만들고 해보겠다.

 

 

그리고 posts.js뒤에 리듀서를 붙여준다.

 

const initialState = {
    posts:{
        loading : false,
        data : null,
        error : null,
    },
    post:{
        loading : false,
        data : null,
        error : null,
    },
}

export default function posts(state = initialState,action) {
    switch(action.type){
        case GET_POSTS:
            return{
                ...state,
                posts:{
                    loading : true,
                    date : null,
                    error : null,
                }
            }
        case GET_POSTS_SUCCESS:
            return{
                ...state,
                posts:{
                    loading : false,
                    date : action.posts,
                    error : null,
                }
            }
        case GET_POSTS_ERROR:
            return{
                ...state,
                posts:{
                    loading : false,
                    date : null,
                    error : action.error,
                }
            }
        case GET_POST:
            return{
                ...state,
                post:{
                    loading : true,
                    date : null,
                    error : null,
                }
            }
        case GET_POST_SUCCESS:
            return{
                ...state,
                post:{
                    loading : false,
                    date : action.post,
                    error : null,
                }
            }
        case GET_POST_ERROR:
            return{
                ...state,
                post:{
                    loading : false,
                    date : null,
                    error : action.error,
                }
            }
        default:
            return state;
    }
}

 

반복되는 코드가 너무 많다고 생각되니 이것을 줄여보자.

 

lib파일을 하나만들고 reducerUtils함수를 하나 만들어서 넣어주자. 그 후에 반복되는 값을을 넣으면 코드가 약간 줄어드는것을 확인할 수 있다.

더보기
//asyncUtils.js

export const reducerUtils = {
    initial : (initialData =  null) => ({
        data,
        loading : false,
        error : null
    }),

    loading : (prevState = null) => ({
        data : prevState,
        loading : true,
        error : null
    }),
    success  : data => ({
        data,
        loading : false,
        error : null
    }),
    error : error => ({
        data : null,
        loading : false,
        error
    })
};

 

 

 

//.modules/posts.js

import * as postsAPI from '../api/posts';//postsAPI.getpost등으로 사용가능하게
import { reducerUtils } from '../lib/asyncUtils';

/*액션*/
const GET_POSTS = 'GET_POSTS';
const GET_POSTS_SUCCESS = 'GET_POSTS_SUCCESS';
const GET_POSTS_ERROR = 'GET_POSTS_ERROR';

const GET_POST = 'GET_POST';
const GET_POST_SUCCESS = 'GET_POST_SUCCESS';
const GET_POST_ERROR = 'GET_POST_ERROR';  

/*thunk */
export const getPosts = () => async dispatch => {
    //요청 시작
    dispatch({type : GET_POSTS });

    //API호출
    try{
        const posts = await postsAPI.getPosts();
        dispatch({
            type : GET_POSTS_SUCCESS,
            posts
        });
    } catch (e) {
        dispatch({
            type : GET_POSTS_ERROR,
            error : e
        })
    }
}

/*thunk */
export const getPost = (id) => async dispatch => { 
    //요청 시작
    dispatch({type : GET_POST });

    //API호출
    try{
        const post = await postsAPI.getPost(id);
        dispatch({
            type : GET_POST_SUCCESS,
            post
        });
    } catch (e) {
        dispatch({
            type : GET_POST_ERROR,
            error : e
        })
    }
};


const initialState = {
    posts:reducerUtils.initial(),
    post:reducerUtils.initial()
}

export default function posts(state = initialState,action) {
    switch(action.type){
        case GET_POSTS:
            return{
                ...state,
                posts:reducerUtils.loading()
            }
        case GET_POSTS_SUCCESS:
            return{
                ...state,
                posts:reducerUtils.success(action.posts)
            }
        case GET_POSTS_ERROR:
            return{
                ...state,
                posts:reducerUtils.error(action.error)
            }
        case GET_POST:
            return{
                ...state,
                post:reducerUtils.loading()
            }
        case GET_POST_SUCCESS:
            return{
                ...state,
                post:reducerUtils.success(action.post)
            }
        case GET_POST_ERROR:
            return{
                ...state,
                post:reducerUtils.error(action.error)
            }
        default:
            return state;
    }
}

하지만 아직도 줄일수 있어보이는 코드들이 상당히 많다. 

 

 

그렇다면 thunk함수와 reducer도 비슷해 보이는 코드가 많으니 리팩토링을 해보자.

 

더보기
//asyncUtils.js

export const createPromiseThunk = (type, promiseCreator) => {   //promiseCreater : Promise를 만드는 함수
    const [SUCCESS,ERROR] = [`${type}_SUCCESS`, `${type}_ERROR`];

    return param => async dispatch => {    //param은하나라고 가정 (여러개면 객체로 받아오는형식)
        dispatch({type})
        try {
            const payload = await promiseCreator(param);
            dispatch({
                type : SUCCESS,
                payload
            })
        } catch (e) {
            dispatch({
                type : ERROR,
                payload : e,
                error : true,
            })
        }
    }
    
}

export const handleAsyncActions = (type,key) => {   //key : posts or post
    const [SUCCESS,ERROR] = [`${type}_SUCCESS`, `${type}_ERROR`];

    return (state,action) => {
        switch(action.type){
            case type:
                return {
                    ...state,
                    [key] :reducerUtils.loading(),
                }
            case SUCCESS:
                return {
                    ...state,
                    [key] : reducerUtils.success(action.payload)
                }
            case ERROR :
                return {
                    ...state,
                    [key] : reducerUtils.error(action.payload)
                }
            default:
                return state
        }
    }

}


export const reducerUtils = {
    initial : (initialData =  null) => ({
        data : initialData,
        loading : false,
        error : null
    }),

    loading : (prevState = null) => ({
        data : prevState,
        loading : true,
        error : null
    }),
    success  : data => ({
        data,
        loading : false,
        error : null
    }),
    error : error => ({
        data : null,
        loading : false,
        error
    })
};

 

//.modules/posts.js

import * as postsAPI from '../api/posts';//postsAPI.getpost등으로 사용가능하게
import { createPromiseThunk, handleAsyncActions, reducerUtils } from '../lib/asyncUtils';

/*액션*/
const GET_POSTS = 'GET_POSTS';
const GET_POSTS_SUCCESS = 'GET_POSTS_SUCCESS';
const GET_POSTS_ERROR = 'GET_POSTS_ERROR';

const GET_POST = 'GET_POST';
const GET_POST_SUCCESS = 'GET_POST_SUCCESS';
const GET_POST_ERROR = 'GET_POST_ERROR';  

/*thunk */
export const getPosts = createPromiseThunk(GET_POSTS, postsAPI.getPosts);

/*thunk */ 
export const getPost =  createPromiseThunk(GET_POST, postsAPI.getPostById)


const initialState = {
    posts:reducerUtils.initial(),
    post:reducerUtils.initial()
}


const getPostsReducer = handleAsyncActions(GET_POSTS,'posts');
const getPostReducer = handleAsyncActions(GET_POST,'post');

export default function posts(state = initialState,action) {
    switch(action.type){
        case GET_POSTS:
        case GET_POSTS_SUCCESS:
        case GET_POSTS_ERROR:
            return getPostsReducer(state,action)
        case GET_POST:
        case GET_POST_SUCCESS:
        case GET_POST_ERROR:
            return getPostReducer(state,action);
        default:
            return state;
    }
}

코드가 매우매우 짧아졌다. thunk생성할때 data값이든 error든 payload로 반한되기에 리듀서도 짧게 만들수 있었다.

 

 

//modules/index.js
import { combineReducers } from "redux"; //root리듀서를 만들기 위해
import counter from "./counter";
import posts from './posts';

const rootReducer = combineReducers({counter,posts});

export default rootReducer;

이제 만든 posts를 추가해주면 된다.

 

 

이제  컴포넌트와 컨테이너를 만들어주자.

 

//PostList.js

import React from 'react';

function PostList({posts}) {
    return (
        <ul>
            {
                posts.map(post => <li key = {post.id}>{post.title}</li>)
            }
        </ul>
    );
    
}


export default PostList;
//PostListContainer.js

import React, {useEffect} from 'react';
import { useSelector, useDispatch } from 'react-redux';
import PostList from '../components/PostList';
import { getPosts } from '../modules/posts';

function PostListContainer() {
    const {data, loading, error } = useSelector(state => state.posts.posts) // index.js안의 posts안의 posts
    const dispatch = useDispatch();

    useEffect(() => {
        dispatch(getPosts())
    },[dispatch])

    if (loading) return <div>로딩중..</div>
    if (error) return <div>에러 발생!</div>
    if (!data) return  null;

    return <PostList posts = {data} />
    
}


export default PostListContainer;

잘 출력되는것을 확인할 수 있다.

 

이제 저번에 공부했던 라우터와 연동해보자!

 

//index.js
import React from 'react';
import ReactDOM from 'react-dom';
import './index.css';
import App from './App';
import reportWebVitals from './reportWebVitals';
import { createStore , applyMiddleware } from 'redux';
import { Provider } from 'react-redux';
import rootReducer from './modules';
import logger from 'redux-logger';
import {composeWithDevTools} from 'redux-devtools-extension';  //개발자도구 적용
import ReduxThunk from 'redux-thunk';
import { BrowserRouter } from 'react-router-dom';


const store = createStore(
  rootReducer, 
  composeWithDevTools(applyMiddleware(ReduxThunk, logger)));//미들웨어 적용



ReactDOM.render(
  <BrowserRouter>
    <Provider store={store}>
      <App />
    </Provider>
  </BrowserRouter>,
  document.getElementById('root')
);

먼저 Router라이브러리를 설치해주고 BrowserRouter로 감싸준다.

 

yarn add react-router-dom@5.2.0

 

v6버전말고 v5버전으로 설치했다!

 

 

post 컴포넌트와 컨테이너를 먼저 작성해준다.

 

더보기
//components/Post.js

import React from 'react';

function Post({ post }) {
    const {title,body} = post;
    return (
        <div>
            <h1>{title}</h1>
            <p>{body}</p>
        </div>
    );
    
}

export default Post;
//containers/PostContainer
import React , {useEffect} from 'react';
import {useSelector,useDispatch} from 'react-redux';
import Post from '../components/Post'
import { getPost } from '../modules/posts';

function PostContainer({postId}) {

    const { data, loading, error } =useSelector(state => state.posts.post);
    const dispatch = useDispatch();

    useEffect(() => {
        dispatch(getPost(postId));
    }, [postId, dispatch]);

    if(loading) return <div>로딩중..</div>
    if (error) return <div>에러 발생!</div>
    if (!data) return null;

    return (
        <Post post ={ data }/>
    );
    
}


export default PostContainer;

 

그리고 라우터로 읽어올 PostPage.js와 PostListPage를 만들어준다. pages파일을 하나 만들어서 그 안에 넣어주었다.

//PostListPage.js

import React from 'react';
import PostListContainer from '../containers/PostListContainer';

function PostListPage() {
  return <PostListContainer />;
}

export default PostListPage;
//pages/PostPage.js

import React from 'react';
import PostContainer from '../containers/PostContainer';

function PostPage({match}) {
    const {id} = match.params;
    const postId = parseInt(id,10); //숫자를 문자열로 변환
    return (
        <PostContainer postId = {postId} />
    );
    
}


export default PostPage;

 

잘 나오는것을 볼 수 있다.

 

 

이 경우 포스트를 들어갔다가 목록으로가면 다시 로딩중... 이란 문구가 다시 뜨는데 정보가 있는데 다시 받아오는것이라  생각할 수 있다. 이를 한번 고쳐보자.

 

 

PostListContainer.js에서

useEffect(() => {
        if (data) return;
        dispatch(getPosts())
    },[dispatch,data])

데이터가 있다면 아무일도 하지않게 하면 된다.

 

 

또다른방법도 있다.

 

지금은 /moudles/posts.js에서 handleAsyncActions(GET_POSTS,'posts')에서 리듀서를 받아오고 있다.

 

export const handleAsyncActions = (type,key, keepData) => {   //key : posts or post
    const [SUCCESS,ERROR] = [`${type}_SUCCESS`, `${type}_ERROR`];

    return (state,action) => {
        switch(action.type){
            case type:
                return {
                    ...state,
                    [key] :reducerUtils.loading(keepData ? state[key].data : null),
                }
            case SUCCESS:
                return {
                    ...state,
                    [key] : reducerUtils.success(action.payload)
                }
            case ERROR :
                return {
                    ...state,
                    [key] : reducerUtils.error(action.payload)
                }
            default:
                return state
        }
    }

}

keepData변수를 추가해서 keepData가 true라면 스킵하게 하는 코드를 작성해준다.

 

reducer에서 loading이

 

loading : (prevState = null) => ({
        data : prevState,
        loading : true,
        error : null
    }),

위처럼 되어있기에 빈칸이 아닌 이전데이터를 바로 넣어주어서 로딩중을 없애주는 것이다.

 

//PostListContainer.js

import React, {useEffect} from 'react';
import { useSelector, useDispatch } from 'react-redux';
import PostList from '../components/PostList';
import { getPosts } from '../modules/posts';

function PostListContainer() {
    const {data, loading, error } = useSelector(state => state.posts.posts) // index.js안의 posts안의 posts
    const dispatch = useDispatch();

    useEffect(() => {
        dispatch(getPosts())
    },[dispatch])

    if (loading && !data) return <div>로딩중..</div>   //데이터가 있다면 로딩중이 안뜨게
    if (error) return <div>에러 발생!</div>
    if (!data) return  null;

    return <PostList posts = {data} />
    
}


export default PostListContainer;

로딩중에 데이터가 있다면 로딩중이 안뜨게 설정하면 된다.

 

 

또 포스트를 눌러서 들어가면, 이전에 들어갔던 포스트의 정보가 살짝씩 보이는 문제가 있는데, 이는 포스트를 나갈때 정보를 삭제하는 방식을 추가해주면 해결할 수 있다.

 

 

더보기
//.modules/posts.js

import * as postsAPI from '../api/post';//postsAPI.getpost등으로 사용가능하게
import { createPromiseThunk, handleAsyncActions, reducerUtils } from '../lib/asyncUtils';

/*액션*/
const GET_POSTS = 'GET_POSTS';
const GET_POSTS_SUCCESS = 'GET_POSTS_SUCCESS';
const GET_POSTS_ERROR = 'GET_POSTS_ERROR';

const GET_POST = 'GET_POST';
const GET_POST_SUCCESS = 'GET_POST_SUCCESS';
const GET_POST_ERROR = 'GET_POST_ERROR';  

const CLEAR_POST = 'CLEAR_POST';   //이전 포스트가 잠깐 보이는 문제를 해결하기 위해

/*thunk */
export const getPosts = createPromiseThunk(GET_POSTS, postsAPI.getPosts);

/*thunk */ 
export const getPost =  createPromiseThunk(GET_POST, postsAPI.getPostById)

export const clearPost = () => ({ type : CLEAR_POST});


const initialState = {
    posts:reducerUtils.initial(),
    post:reducerUtils.initial()
}


const getPostsReducer = handleAsyncActions(GET_POSTS,'posts',true);
const getPostReducer = handleAsyncActions(GET_POST,'post');

export default function posts(state = initialState,action) {
    switch(action.type){
        case GET_POSTS:
        case GET_POSTS_SUCCESS:
        case GET_POSTS_ERROR:
            return getPostsReducer(state,action)
        case GET_POST:
        case GET_POST_SUCCESS:
        case GET_POST_ERROR:
            return getPostReducer(state,action);
        case CLEAR_POST:   
        return{
            ...state,
            post : reducerUtils.initial(),
        }
        default:
            
            return state;
    }
}

먼저 위 코드에서처럼 CLEAR_POST액션을 추가해준다.

 

그 후 ,PostContainer에서

 

useEffect(() => {
        dispatch(getPost(postId));
        return () => {   //언마운트되거나 postId가 바뀔 때 호출
            dispatch(clearPost())
        };
    }, [postId, dispatch]);

clearPost를 넣어주면 된다.

 

 

 

 

포스트 데이터 리덕스 상태구조 바꾸기

 

현재 posts모듈에서는 아래와 같이 상태를 관리하고 있다.

 

{
  posts: {
    data,
    loading,
    error
  },
  post: {
    data,
    loading,
    error,
  }
}

구조를 아래와 같이와 같이 바꾸어서 재사용도 가능하게 구조를 바꾸어보겠다.

{
  posts: {
    data,
    loading,
    error
  },
  post: {
    '1': {
      data,
      loading,
      error
    },
    '2': {
      data,
      loading,
      error
    },
    [id]: {
      data,
      loading,
      error
    }
  }
}

 

즉, id값을 새로 받아와서 조회하는 방식이다. 그래서 thunk함수부터 다시한번 작성해주자.

 

더보기
//.modules/posts.js

import * as postsAPI from '../api/post';//postsAPI.getpost등으로 사용가능하게
import { createPromiseThunk, handleAsyncActions, reducerUtils } from '../lib/asyncUtils';

/*액션*/
const GET_POSTS = 'GET_POSTS';
const GET_POSTS_SUCCESS = 'GET_POSTS_SUCCESS';
const GET_POSTS_ERROR = 'GET_POSTS_ERROR';

const GET_POST = 'GET_POST';
const GET_POST_SUCCESS = 'GET_POST_SUCCESS';
const GET_POST_ERROR = 'GET_POST_ERROR';  

const CLEAR_POST = 'CLEAR_POST';   //이전 포스트가 잠깐 보이는 문제를 해결하기 위해

/*thunk */
export const getPosts = createPromiseThunk(GET_POSTS, postsAPI.getPosts);

/*thunk */ 
export const getPost =  id => async dispatch => {
    dispatch({type : GET_POST, meta : id});
    try{
        const payload = await postsAPI.getPostById(id);
        dispatch({ type : GET_POST_SUCCESS, payload, meta : id });
    } catch (e) {
        dispatch({
            type : GET_POST_ERROR,
            payload : e,
            error : true,
            meta : id
        })
    }
}

export const clearPost = () => ({ type : CLEAR_POST});


const initialState = {
    posts:reducerUtils.initial(),
    post: {}
}


const getPostsReducer = handleAsyncActions(GET_POSTS,'posts',true);
const getPostReducer = (state,action) => {
    const id = action.meta;
    switch (action.type) {
        case GET_POST:
            return{
                ...state,
                post : {
                    ...state.post,
                    [id] : reducerUtils.loading(state.post[id] && state.post[id].data) //맨처음에는 undefind이기 때문에
                }
            };
        case GET_POST_SUCCESS:
            return{
                ...state,
                post : {
                    ...state.post,
                    [id] : reducerUtils.success(action.payload) 
                }
            };
        case GET_POST_ERROR:
            return{
                ...state,
                post : {
                    ...state.post,
                    [id] : reducerUtils.error(action.payload) 
                }
            };
        default:
            return state
    }
}

export default function posts(state = initialState,action) {
    switch(action.type){
        case GET_POSTS:
        case GET_POSTS_SUCCESS:
        case GET_POSTS_ERROR:
            return getPostsReducer(state,action)
        case GET_POST:
        case GET_POST_SUCCESS:
        case GET_POST_ERROR:
            return getPostReducer(state,action);
        case CLEAR_POST:   
        return{
            ...state,
            post : reducerUtils.initial(),
        }
        default:
            
            return state;
    }
}

 

 

PostContainer도 역시 수정해주자. 코드방식은 비슷하다.

 

더보기
//containers/PostContainer
import React , {useEffect} from 'react';
import {useSelector,useDispatch} from 'react-redux';
import Post from '../components/Post'
import { reducerUtils } from '../lib/asyncUtils';
import {  getPost } from '../modules/posts';

function PostContainer({postId}) {

    const { data, loading, error } =useSelector(state => state.posts.post[postId] || reducerUtils.initial()); //에러방지를 위해
    const dispatch = useDispatch();

    useEffect(() => {
        if (data) return; //데이터가 있다면 아무것도 안함
        dispatch(getPost(postId));
    }, [postId, dispatch]);

    if(loading && !data) return <div>로딩중..</div>
    if (error) return <div>에러 발생!</div>
    if (!data) return null;

    return (
         <Post post={data} />
        
    );
    
}


export default PostContainer;

 

위코드들 역시 리팩토링이 필요하다.

 

const defaultIdSelector = param => param; //파라미터 자체가 아이디이다.

export const createPromiseThunkById = (type, promiseCreator, idSelector = defaultIdSelector) =>{
    const [SUCCESS,ERROR] = [`${type}_SUCCESS`, `${type}_ERROR`];

    return param => async dispatch => {    //param은하나라고 가정 (여러개면 객체로 받아오는형식)
        const id = idSelector(param);
        dispatch({type, meta : id})
        try {
            const payload = await promiseCreator(param);
            dispatch({
                type : SUCCESS,
                payload,
                meta : id
            })
        } catch (e) {
            dispatch({
                type : ERROR,
                payload : e,
                error : true,
                meta : id
            })
        }
    };
};

asyncUtils파일안에 다음과같은 함수를 하나 더 만들어주자. 이전에 만들었던 코드와 유사하지만, id값을 받아오는게 생겼고, dispatch에도 meta값으로 id를 전해주는 부분이 추가되었다.

 

 

최종 리팩토링한 코드는 아래와 같다.

 

더보기
//asyncUtils.js

export const createPromiseThunk = (type, promiseCreator) => {   //promiseCreater : Promise를 만드는 함수
    const [SUCCESS,ERROR] = [`${type}_SUCCESS`, `${type}_ERROR`];

    return param => async dispatch => {    //param은하나라고 가정 (여러개면 객체로 받아오는형식)
        dispatch({type})
        try {
            const payload = await promiseCreator(param);
            dispatch({
                type : SUCCESS,
                payload
            })
        } catch (e) {
            dispatch({
                type : ERROR,
                payload : e,
                error : true,
            })
        }
    };
    
};

const defaultIdSelector = param => param; //파라미터 자체가 아이디이다.

export const createPromiseThunkById = (type, promiseCreator, idSelector = defaultIdSelector) =>{
    const [SUCCESS,ERROR] = [`${type}_SUCCESS`, `${type}_ERROR`];

    return param => async dispatch => {    //param은하나라고 가정 (여러개면 객체로 받아오는형식)
        const id = idSelector(param);
        dispatch({type, meta : id})
        try {
            const payload = await promiseCreator(param);
            dispatch({
                type : SUCCESS,
                payload,
                meta : id
            })
        } catch (e) {
            dispatch({
                type : ERROR,
                payload : e,
                error : true,
                meta : id
            })
        }
    };
};


export const handleAsyncActionsById = (type,key, keepData) => {   //key : posts or post
    const [SUCCESS,ERROR] = [`${type}_SUCCESS`, `${type}_ERROR`];

    return (state,action) => {
        const id = action.meta;
        switch(action.type){
            case type:
                return {
                    ...state,
                    [key] :{
                        ...state[key],
                        [id] : reducerUtils.loading(keepData ? (state[key][id] && state[key][id].data) : null),
                    }
                }
            case SUCCESS:
                return {
                    ...state,
                    [key] : {
                        ...state[key],
                        [id] : reducerUtils.success(action.payload)
                    }
                }
            case ERROR :
                return {
                    ...state,
                    [key] : {
                        ...state[key],
                        [id] : reducerUtils.error(action.payload)
                    }
                }
            default:
                return state
        }
    }

}

export const handleAsyncActions = (type,key, keepData) => {   //key : posts or post
    const [SUCCESS,ERROR] = [`${type}_SUCCESS`, `${type}_ERROR`];

    return (state,action) => {
        switch(action.type){
            case type:
                return {
                    ...state,
                    [key] :reducerUtils.loading(keepData ? state[key].data : null),
                }
            case SUCCESS:
                return {
                    ...state,
                    [key] : reducerUtils.success(action.payload)
                }
            case ERROR :
                return {
                    ...state,
                    [key] : reducerUtils.error(action.payload)
                }
            default:
                return state
        }
    }

}


export const reducerUtils = {
    initial : (initialData =  null) => ({
        data : initialData,
        loading : false,
        error : null
    }),

    loading : (prevState = null) => ({
        data : prevState,
        loading : true,
        error : null
    }),
    success  : data => ({
        data,
        loading : false,
        error : null
    }),
    error : error => ({
        data : null,
        loading : false,
        error
    })
};

 

//.modules/posts.js

import * as postsAPI from '../api/post';//postsAPI.getpost등으로 사용가능하게
import { createPromiseThunk, createPromiseThunkById, handleAsyncActions, handleAsyncActionsById, reducerUtils } from '../lib/asyncUtils';

/*액션*/
const GET_POSTS = 'GET_POSTS';
const GET_POSTS_SUCCESS = 'GET_POSTS_SUCCESS';
const GET_POSTS_ERROR = 'GET_POSTS_ERROR';

const GET_POST = 'GET_POST';
const GET_POST_SUCCESS = 'GET_POST_SUCCESS';
const GET_POST_ERROR = 'GET_POST_ERROR';  

const CLEAR_POST = 'CLEAR_POST';   //이전 포스트가 잠깐 보이는 문제를 해결하기 위해

/*thunk */
export const getPosts = createPromiseThunk(GET_POSTS, postsAPI.getPosts);

/*thunk */ 
export const getPost =  createPromiseThunkById(GET_POST, postsAPI.getPostById)

export const clearPost = () => ({ type : CLEAR_POST});


const initialState = {
    posts:reducerUtils.initial(),
    post: {}
}


const getPostsReducer = handleAsyncActions(GET_POSTS,'posts',true);
const getPostReducer = handleAsyncActionsById(GET_POST,'post',true);

export default function posts(state = initialState,action) {
    switch(action.type){
        case GET_POSTS:
        case GET_POSTS_SUCCESS:
        case GET_POSTS_ERROR:
            return getPostsReducer(state,action)
        case GET_POST:
        case GET_POST_SUCCESS:
        case GET_POST_ERROR:
            return getPostReducer(state,action);
        case CLEAR_POST:   
        return{
            ...state,
            post : reducerUtils.initial(),
        }
        default:
            
            return state;
    }
}

모듈쪽 코드가 상당히 간결해진것을 알 수 있다.

 

 

 

Thunk함수에서 리액트 라우터 History 사용하기

 

사용자가 로그인을 하면 특정 경로로가고 아니면 현재경로를 유지하는 등의 기능을 할때 유용하다.

 

먼저 기존에 BrowserRouter로 감싸주었던 부분을 Router로 감싸주고, createBrowserHistory를 통해 customHistory를 만들고 다음처럼 감싸준다.

//index.js
import React from 'react';
import ReactDOM from 'react-dom';
import './index.css';
import App from './App';
import reportWebVitals from './reportWebVitals';
import { createStore , applyMiddleware } from 'redux';
import { Provider } from 'react-redux';
import rootReducer from './modules';
import logger from 'redux-logger';
import {composeWithDevTools} from 'redux-devtools-extension';  //개발자도구 적용
import ReduxThunk from 'redux-thunk';
import { Router } from 'react-router-dom';
import {createBrowserHistory} from 'react-router-dom';

const customHistory = createBrowserHistory();


const store = createStore(
  rootReducer, 
  composeWithDevTools(applyMiddleware(ReduxThunk.withExtraArgument({history : customHistory}), logger)));//미들웨어 적용



ReactDOM.render(
  <Router history  = {customHistory}>
    <Provider store={store}>
      <App />
    </Provider>
  </Router>,
  document.getElementById('root')
);

// If you want to start measuring performance in your app, pass a function
// to log results (for example: reportWebVitals(console.log))
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
reportWebVitals();

 

그 후에 post.js 모듈에서

 

 

/*thunk */
export const getPosts = createPromiseThunk(GET_POSTS, postsAPI.getPosts);
export const getPost =  createPromiseThunkById(GET_POST, postsAPI.getPostById)
export const goToHome = () => (dispatch,getState,{history}) => {   // 3번째 파라미터안에 history가있어 비구조화 할당으로 가져옴
    history.push('/');                           //withExtraargument에서 extra가 들어오는데 그안에 history가 있다
}

다음과 같이 홈화면으로 이동하는 thunk함수를 하나 만들어준다.

 

 

//containers/PostContainer
import React , {useEffect} from 'react';
import {useSelector,useDispatch} from 'react-redux';
import Post from '../components/Post'
import { reducerUtils } from '../lib/asyncUtils';
import {  getPost, goToHome } from '../modules/posts';

function PostContainer({postId}) {

    const { data, loading, error } =useSelector(state => state.posts.post[postId] || reducerUtils.initial()); //에러방지를 위해
    const dispatch = useDispatch();

    useEffect(() => {
        if (data) return; //데이터가 있다면 아무것도 안함
        dispatch(getPost(postId));
    }, [postId, dispatch]);

    if(loading && !data) return <div>로딩중..</div>
    if (error) return <div>에러 발생!</div>
    if (!data) return null;

    return (
        <>
            <button onClick={ () => dispatch(goToHome()) }>홈으로 이동</button>
            <Post post={data} />
        </>
        
    );
    
}


export default PostContainer;

다음 만든 thunk함수를 실행시켜주는 버튼을 하나 만들어서 연결해주면 

 

 

홈 버튼이 생기고 이동할 수 있는것을 알 수 있다.

 

 

지금까지는 가짜 api를 만들어서 실습해보았는데, 다음 글에서는 진짜 api를 가지고 테스트해보자.

728x90

'FrontEnd > React' 카테고리의 다른 글

25_타입스크립트 & 리액트  (0) 2022.01.07
23_리액트 리덕스 미들웨어(2)  (0) 2022.01.05
21_리액트_리덕스  (0) 2022.01.02
20_리액트_라우터  (0) 2021.12.30
19_리액트 API연동  (0) 2021.12.29