[React/Dave Gray] React Redux Toolkit #7 React, Redux Toolkit, RTK Query Project

최예린·2022년 10월 15일
0

React

목록 보기
15/19

RTK Query 공부 참고자료

  • 기본사용법, 개념 블로그
    https://help-solomon.tistory.com/m/63
    Redux Toolkit core 위에서 작성된 Data Fetching, Caching Tool
    기존 Redux에 비해 훨씬 적은 양의 코드로 처리하여 상태 관리를 할 수 있다.

    그 이유는 API 요청부터 데이터를 받아서 바로 Store에 저장하는 단계를 쭉 이어서 처리하기 때문이다.

블로그 만들기

  • 변환
    Axios async thunks -> RTK Query, 정규화된 캐시상태, 낙관적 업데이트

Home


모든 글을 최근에 작성된 순서로 정렬해서 보여준다.

  • 제목
  • 본문
  • 작성자, 작성시간
  • 이모지

Add a New Post


글을 업로드 할 수 있다.

사용자 목록

오른쪽 상단의 Users를 클릭하면 모든사용자를 보여준다.

사용자별 작성글 목록

사용자 링크를 클릭해서 이동하면
그 사용자가 작성한 글 목록을 볼 수 있다.

상세보기


글 링크를 클릭하면 그 글만 볼 수 있으며
이 페이지에서는 글을 수정하는 링크가 있다.

Edit Post

전체 파일구조 미리보기


JSON Server

npm i json-server -g
json-server --watch data/db.json --port 3500

data/db.json파일에는 users와 posts 두가지 데이터가 모두 들어있습니다.


features/api/apiSlice.js 생성

import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'

export const apiSlice = createApi({
    reducerPath: 'api', // optional
    baseQuery: fetchBaseQuery({ baseUrl: 'http://localhost:3500' }),
    tagTypes: ['Post', 'User'],
    endpoints: builder => ({})
})

reducerPath는 defulat 이기때문에 넣어도되고 안넣어도 됩니다.
endpoints는 postsSlice.js와 userSlice.js에서 확장된 apiSlice를 만들때에 작성해줍니다. 여기에서는 아무것도 하지않지만 가지고는 있어야합니다.

features/posts/postsSlice.js

import {
    createSelector,
    createEntityAdapter
} from "@reduxjs/toolkit";
import { sub } from 'date-fns';
import { apiSlice } from "../api/apiSlice";

const postsAdapter = createEntityAdapter({
    sortComparer: (a, b) => b.date.localeCompare(a.date)
})

const initialState = postsAdapter.getInitialState()

export const extendedApiSlice = apiSlice.injectEndpoints({
    endpoints: builder => ({
        getPosts: builder.query({
            query: () => '/posts',
            transformResponse: responseData => {
                let min = 1;
                const loadedPosts = responseData.map(post => {
                    if (!post?.date) post.date = sub(new Date(), { minutes: min++ }).toISOString();
                    if (!post?.reactions) post.reactions = {
                        thumbsUp: 0,
                        wow: 0,
                        heart: 0,
                        rocket: 0,
                        coffee: 0
                    }
                    return post;
                });
                return postsAdapter.setAll(initialState, loadedPosts)
            },
            providesTags: (result, error, arg) => [
                { type: 'Post', id: "LIST" },
                ...result.ids.map(id => ({ type: 'Post', id }))
            ]
        }),
        getPostsByUserId: builder.query({
            query: id => `/posts/?userId=${id}`,
            transformResponse: responseData => {
                let min = 1;
                const loadedPosts = responseData.map(post => {
                    if (!post?.date) post.date = sub(new Date(), { minutes: min++ }).toISOString();
                    if (!post?.reactions) post.reactions = {
                        thumbsUp: 0,
                        wow: 0,
                        heart: 0,
                        rocket: 0,
                        coffee: 0
                    }
                    return post;
                });
                return postsAdapter.setAll(initialState, loadedPosts)
            },
            providesTags: (result, error, arg) => [
                ...result.ids.map(id => ({ type: 'Post', id }))
            ]
        }),
        addNewPost: builder.mutation({
            query: initialPost => ({
                url: '/posts',
                method: 'POST',
                body: {
                    ...initialPost,
                    userId: Number(initialPost.userId),
                    date: new Date().toISOString(),
                    reactions: {
                        thumbsUp: 0,
                        wow: 0,
                        heart: 0,
                        rocket: 0,
                        coffee: 0
                    }
                }
            }),
            invalidatesTags: [
                { type: 'Post', id: "LIST" }
            ]
        }),
        updatePost: builder.mutation({
            query: initialPost => ({
                url: `/posts/${initialPost.id}`,
                method: 'PUT',
                body: {
                    ...initialPost,
                    date: new Date().toISOString()
                }
            }),
            invalidatesTags: (result, error, arg) => [
                { type: 'Post', id: arg.id }
            ]
        }),
        deletePost: builder.mutation({
            query: ({ id }) => ({
                url: `/posts/${id}`,
                method: 'DELETE',
                body: { id }
            }),
            invalidatesTags: (result, error, arg) => [
                { type: 'Post', id: arg.id }
            ]
        }),
        addReaction: builder.mutation({
            query: ({ postId, reactions }) => ({
                url: `posts/${postId}`,
                method: 'PATCH',
                // In a real app, we'd probably need to base this on user ID somehow
                // so that a user can't do the same reaction more than once
                body: { reactions }
            }),
            async onQueryStarted({ postId, reactions }, { dispatch, queryFulfilled }) {
                // `updateQueryData` requires the endpoint name and cache key arguments,
                // so it knows which piece of cache state to update
                const patchResult = dispatch(
                    extendedApiSlice.util.updateQueryData('getPosts', undefined, draft => {
                        // The `draft` is Immer-wrapped and can be "mutated" like in createSlice
                        const post = draft.entities[postId]
                        if (post) post.reactions = reactions
                    })
                )
                try {
                    await queryFulfilled
                } catch {
                    patchResult.undo()
                }
            }
        })
    })
})

export const { // use_____Query, use_____Mutation 형식으로 정의합니다. 이름이 틀리면 에러발생
    useGetPostsQuery,
    useGetPostsByUserIdQuery,
    useAddNewPostMutation,
    useUpdatePostMutation,
    useDeletePostMutation,
    useAddReactionMutation
} = extendedApiSlice



// returns the query result object
export const selectPostsResult = extendedApiSlice.endpoints.getPosts.select()

// Creates memoized selector
const selectPostsData = createSelector(
    selectPostsResult,
    postsResult => postsResult.data // normalized state object with ids & entities
)

//getSelectors creates these selectors and we rename them with aliases using destructuring
export const {
    selectAll: selectAllPosts,
    selectById: selectPostById,
    selectIds: selectPostIds
    // Pass in a selector that returns the posts slice of state
} = postsAdapter.getSelectors(state => selectPostsData(state) ?? initialState)

위에서 만들어둔 apiSlice를 가져와서 extendedApi를 만들어줍니다.
apiSlice에서 비워두었던 endpoints를 추가해줍니다.

query endpoint를 구독할때, 캐시 데이터가 아직 존재하지않을때만 요청이 보내집니다. 이미 존재한다면, 존재하는 데이터(캐싱된 데이터)가 대신 제공됩니다.

RTK 쿼리는 "캐시 태그cache tag" 시스템을 사용합니다. 캐시태그 시스템은 endpoint가 수정되었을때 영향을 받는 데이터를 포함하고있는 query endpoints를 위해서 자동으로 re-fetching하는 시스템입니다.

  • tag: 캐싱과 무효화를 refetching 목적으로 컨트롤하기위해 특정 데이터 모음에 주는 이름. tagType:[] 에 정의 됨.
  • providesTags : string 배열(ex. ['Post']) or 배열을 반환하는 콜백
  • mutation은 특정 캐시된 데이터를 태그에 따라서 무효화할수있습니다. 그렇게해서 어떤 캐시 데이터가 refetch되거나 캐시에서 제거될지 결정할 수 있습니다.
  • invalidatesTags: string 배열(ex. ['Post']) or 배열을 반환하는 콜백

예시 ) sub(date, {minutes: 1}) 이면 date에 저장된 minutes의 값을 1 빼는것을 의미합니다.

아래 두개의 필드를 포함하는 객체를 인자로 받습니다.

  1. selectId
    단일 Entity인스턴스를 받고 내부에 있는 고유한 ID 필드의 값을 반환하는 함수입니다.
  2. sortComparer
    정렬을 위한 상대 순서를 나타내기 위해 표준 숫자 결과(1, 0, -1)를 반환해야 하는 콜백 함수입니다.

addReaction은 구현방법이 조금 다릅니다.
draft는 ImmerJS로 감싸져있기떄문에 mutate가 가능합니다.
그래서 draft.entities[postId] 로 특정 포스트를 가져온 뒤 만약 포스트가 존재한다면 포스트의 리액션을 인자로 받은 reactions로 mutate해줍니다.

그리고 위의 코드는 낙관적 업데이트로 구현되어있습니다. 낙관적 업데이트란 "내가 보낸 요청은 무조건 '성공'했을 거야!!" 라고 가정하고 서버에서 응답이 오기 전에 프론트 측에서 UI를 미리 업데이트 하겠다는 것입니다. 만약 실패한다면 그것을 처리하는 방법이 있습니다.
예를들면 일단 리액션을 클릭해서 UI가 바뀌고 사용자가 그걸 보게됩니다. 그 뒤에 서버와 통신을 하게되는데 만약 성공하면 queryFulfilled 하고, 실패하면 catch 블럭에서 작업을 undo() 해줍니다.

app/store.js

import { configureStore } from "@reduxjs/toolkit";
import { apiSlice } from '../features/api/apiSlice';

export const store = configureStore({
    reducer: {
        [apiSlice.reducerPath]: apiSlice.reducer
    },
    middleware: getDefaultMiddleware =>
        getDefaultMiddleware().concat(apiSlice.middleware),
    devTools: true
})

apiSlice에서 reducer를 가져와서 store에 추가해줍니다.

index.js

import React from 'react';
import ReactDOM from 'react-dom';
import './index.css';
import App from './App';
import { store } from './app/store';
import { Provider } from 'react-redux';
import { extendedApiSlice } from './features/posts/postsSlice';
import { usersApiSlice } from './features/users/usersSlice';
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';

store.dispatch(extendedApiSlice.endpoints.getPosts.initiate());
store.dispatch(usersApiSlice.endpoints.getUsers.initiate());

ReactDOM.render(
  <React.StrictMode>
    <Provider store={store}>
      <Router>
        <Routes>
          <Route path="/*" element={<App />} />
        </Routes>
      </Router>
    </Provider>
  </React.StrictMode>,
  document.getElementById('root')
);

extendedApiSlice를 dispatch 해줍니다.
getPost의 초기값을 가져와서 초기화를 해줍니다. 앱이 시작될 때 한번 더 로드할 수 있습니다.

features/posts/PostsList.js

import { useSelector } from "react-redux";
import { selectPostIds } from "./postsSlice";
import PostsExcerpt from "./PostsExcerpt";
import { useGetPostsQuery } from './postsSlice';

const PostsList = () => {
    const {
        isLoading,
        isSuccess,
        isError,
        error
    } = useGetPostsQuery()

    const orderedPostIds = useSelector(selectPostIds)

    let content;
    if (isLoading) {
        content = <p>"Loading..."</p>;
    } else if (isSuccess) {
        content = orderedPostIds.map(postId => <PostsExcerpt key={postId} postId={postId} />)
    } else if (isError) {
        content = <p>{error}</p>;
    }

    return (
        <section>
            {content}
        </section>
    )
}
export default PostsList

GetPost Query 사용자 정의 Hook을 사용할 준비가 되었습니다.
useGetPostsQuery로 GetPost를 가져옵니다.

  • isLoading
  • isSuccess
  • isError
  • error

를 사용할 수 있습니다.

features/posts/AddPostForm.js

import { useState } from "react";
import { useSelector } from "react-redux";

import { selectAllUsers } from "../users/usersSlice";
import { useNavigate } from "react-router-dom";
import { useAddNewPostMutation } from "./postsSlice";

const AddPostForm = () => {
    const [addNewPost, { isLoading }] = useAddNewPostMutation()

    const navigate = useNavigate()

    const [title, setTitle] = useState('')
    const [content, setContent] = useState('')
    const [userId, setUserId] = useState('')

    const users = useSelector(selectAllUsers)

    const onTitleChanged = e => setTitle(e.target.value)
    const onContentChanged = e => setContent(e.target.value)
    const onAuthorChanged = e => setUserId(e.target.value)


    const canSave = [title, content, userId].every(Boolean) && !isLoading;

    const onSavePostClicked = async () => {
        if (canSave) {
            try {
                await addNewPost({ title, body: content, userId }).unwrap()

                setTitle('')
                setContent('')
                setUserId('')
                navigate('/')
            } catch (err) {
                console.error('Failed to save the post', err)
            }
        }
    }

    const usersOptions = users.map(user => (
        <option key={user.id} value={user.id}>
            {user.name}
        </option>
    ))

    return (
        <section>
            <h2>Add a New Post</h2>
            <form>
                <label htmlFor="postTitle">Post Title:</label>
                <input
                    type="text"
                    id="postTitle"
                    name="postTitle"
                    value={title}
                    onChange={onTitleChanged}
                />
                <label htmlFor="postAuthor">Author:</label>
                <select id="postAuthor" value={userId} onChange={onAuthorChanged}>
                    <option value=""></option>
                    {usersOptions}
                </select>
                <label htmlFor="postContent">Content:</label>
                <textarea
                    id="postContent"
                    name="postContent"
                    value={content}
                    onChange={onContentChanged}
                />
                <button
                    type="button"
                    onClick={onSavePostClicked}
                    disabled={!canSave}
                >Save Post</button>
            </form>
        </section>
    )
}
export default AddPostForm

useAddNewPostMutation를 import해서 사용합니다.
isLoading을 가져와서 사용할 수 있습니다.

  • canSave
    기존에 사용하던 requestStatus state 관련 코드를 모두 삭제하고
    isLoading을 통해 저장할수있는지 여부를 확인합니다.
    우리가 직접 state를 관리하면서 처리하는 것보다 코드가 많이 줄어들었습니다.

  • 비동기처리

 const ___ = async () => {
        if (booleanValue) {
            try {
                await _____({ ____, ____, ____}).unwrap()
            } catch (err) {
                console.error('Error', err)
            }
        }
    }

배열에 있는 모든 값들이 테스트를 통과하면 true, 하나라도 통과하지못하면 false를 리턴하는 메서드이다.

features/posts/EditPostForm.js

import { useState } from 'react'
import { useSelector } from 'react-redux'
import { selectPostById } from './postsSlice'
import { useParams, useNavigate } from 'react-router-dom'

import { selectAllUsers } from "../users/usersSlice";
import { useUpdatePostMutation, useDeletePostMutation } from "./postsSlice";

const EditPostForm = () => {
    const { postId } = useParams()
    const navigate = useNavigate()

    const [updatePost, { isLoading }] = useUpdatePostMutation()
    const [deletePost] = useDeletePostMutation()

    const post = useSelector((state) => selectPostById(state, Number(postId)))
    const users = useSelector(selectAllUsers)

    const [title, setTitle] = useState(post?.title)
    const [content, setContent] = useState(post?.body)
    const [userId, setUserId] = useState(post?.userId)

    if (!post) {
        return (
            <section>
                <h2>Post not found!</h2>
            </section>
        )
    }

    const onTitleChanged = e => setTitle(e.target.value)
    const onContentChanged = e => setContent(e.target.value)
    const onAuthorChanged = e => setUserId(Number(e.target.value))

    const canSave = [title, content, userId].every(Boolean) && !isLoading;

    const onSavePostClicked = async () => {
        if (canSave) {
            try {
                await updatePost({ id: post.id, title, body: content, userId }).unwrap()

                setTitle('')
                setContent('')
                setUserId('')
                navigate(`/post/${postId}`)
            } catch (err) {
                console.error('Failed to save the post', err)
            }
        }
    }

    const usersOptions = users.map(user => (
        <option
            key={user.id}
            value={user.id}
        >{user.name}</option>
    ))

    const onDeletePostClicked = async () => {
        try {
            await deletePost({ id: post.id }).unwrap()

            setTitle('')
            setContent('')
            setUserId('')
            navigate('/')
        } catch (err) {
            console.error('Failed to delete the post', err)
        }
    }

    return (
        <section>
            <h2>Edit Post</h2>
            <form>
                <label htmlFor="postTitle">Post Title:</label>
                <input
                    type="text"
                    id="postTitle"
                    name="postTitle"
                    value={title}
                    onChange={onTitleChanged}
                />
                <label htmlFor="postAuthor">Author:</label>
                <select id="postAuthor" value={userId} onChange={onAuthorChanged}>
                    <option value=""></option>
                    {usersOptions}
                </select>
                <label htmlFor="postContent">Content:</label>
                <textarea
                    id="postContent"
                    name="postContent"
                    value={content}
                    onChange={onContentChanged}
                />
                <button
                    type="button"
                    onClick={onSavePostClicked}
                    disabled={!canSave}
                >
                    Save Post
                </button>
                <button className="deleteButton"
                    type="button"
                    onClick={onDeletePostClicked}
                >
                    Delete Post
                </button>
            </form>
        </section>
    )
}

export default EditPostForm

AddPostForm과 마찬가지로 mutation사용, 비동기처리 등을수정해준다.

features/users/UserPage.js

import { useSelector } from 'react-redux'
import { selectUserById } from '../users/usersSlice'
import { Link, useParams } from 'react-router-dom'
import { useGetPostsByUserIdQuery } from '../posts/postsSlice'

const UserPage = () => {
    const { userId } = useParams()
    const user = useSelector(state => selectUserById(state, Number(userId)))

    const {
        data: postsForUser,
        isLoading,
        isSuccess,
        isError,
        error
    } = useGetPostsByUserIdQuery(userId);

    let content;
    if (isLoading) {
        content = <p>Loading...</p>
    } else if (isSuccess) {
        const { ids, entities } = postsForUser
        content = ids.map(id => (
            <li key={id}>
                <Link to={`/post/${id}`}>{entities[id].title}</Link>
            </li>
        ))
    } else if (isError) {
        content = <p>{error}</p>;
    }

    return (
        <section>
            <h2>{user?.name}</h2>

            <ol>{content}</ol>
        </section>
    )
}

export default UserPage

useParams() 로 URL에서 userId를 받아옵니다.
그리고 useGetPostsByUserIdQuery를 import 해온 뒤 userId값을 인자로 넘겨주고

  • data
  • isLoading
  • isSuccess
  • Error
  • error
    등을 받아옵니다.

features/posts/ReactionButtons.js

import { useAddReactionMutation } from './postsSlice'

const reactionEmoji = {
    thumbsUp: '👍',
    wow: '😮',
    heart: '❤️',
    rocket: '🚀',
    coffee: '☕'
}

const ReactionButtons = ({ post }) => {
    const [addReaction] = useAddReactionMutation()

    const reactionButtons = Object.entries(reactionEmoji).map(([name, emoji]) => {
        return (
            <button
                key={name}
                type="button"
                className="reactionButton"
                onClick={() => {
                    const newValue = post.reactions[name] + 1;
                    addReaction({ postId: post.id, reactions: { ...post.reactions, [name]: newValue } })
                }}
            >
                {emoji} {post.reactions[name]}
            </button>
        )
    })

    return <div>{reactionButtons}</div>
}
export default ReactionButtons

useAddReactionMutation를 import해서 addReaction함수를 가져옵니다. 그리고 리액션 버튼을 클릭하면 기존에 있던 리액션 숫자를 받아와서 + 1 한 뒤 newValue에 저장해서 addReadction에 넘겨줍니다.

최종결과물

포스팅 가장 앞에서 살펴봤던 모든 기능들이 정상적으로 작동함을 알수있습니다.

Redux DevTool을 통해 동작들을 확인할 수 있습니다.


user를 클릭하면 userId가 Network에 추가됩니다. 한번 이렇게 불러오고나면 캐시되어 다음번에 같은 user를 클릭했을때는 새로 요청을 하지않습니다.

profile
경북대학교 글로벌소프트웨어융합전공/미디어아트연계전공

0개의 댓글