리덕스에서 비동기 작업을 처리할수 있또록 도와주는 미들웨어.
해당 미들웨어를사용하면 액션 객체가 아니라 함수 그 자체를 디스패치 할수 있도록 해준다.
기존에는 아래와 같이 액셩 생성함수를 작성했었는데 axios 요청을 보내는 액션을 만들때는
action must be plain objects. Use custom middleware for async actions
같은 오류가 나온다. 즉 액션 생성함수를 작성할때는 async,await를 못쓴다는 얘기
이를 위해서 redux thunk는 액션이 액션 객체를 반환하는 것이 아니라, 아래처럼 함수를 반환할수 있도록 해준다.
export const fetchData = () => {
return async function(dispatch, getState) {
const response = await axios.get('url')
return {
type: 'FETCH_DATA',
payload: response.data
}
}
}
yarn add redux thunk
적용하는 코드는 아래와 같다.
import ReduxThunk from 'redux-thunk';
const store = createStore(rootReducer, applyMiddleware(ReduxThunk));
나는 logger,devtools랑 같이 쓰고 싶다?
import { applyMiddleware, compose, legacy_createStore as createStore } from "redux";
import ReduxThunk from 'redux-thunk';
import logger from 'redux-logger';
const composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose;
const store = createStore(rootReducer, composeEnhancers(applyMiddleware(ReduxThunk, logger)));
위의 주소로 GET 요청을 보내고 데이터를 받아서 관리하는 과정을 redux를 통해서 진행하기로 했다.
ducks 방식으로 파일 하나에 리덕스 관련 메소드를 작성한다.
import axios from 'axios'
// 액션 타입
const GET_POSTS = 'posts/GET_POSTS'
const GET_POSTS_SUCCESS = 'posts/GET_POSTS_SUCCESS'
const GET_POSTS_ERROR = 'posts/GET_POSTS_ERROR'
// 액션 객체 넣기
export const getPosts = () => async (dispatch, getState) => {
dispatch({ type: GET_POSTS }) // 요청 시작
try {
const response = await axios.get('https://jsonplaceholder.typicode.com/posts')
dispatch({
type: GET_POSTS_SUCCESS,
payload: { posts: response.data },
}) // 요청 성공
} catch (e) {
dispatch({ type: GET_POSTS_ERROR, error: e }) // 요청 실패
}
}
// 초기값
const initialState = {
loading: false,
data: null,
error: null,
}
// 추가 / 제거
export default function posts(state = initialState, action) {
switch (action.type) {
case GET_POSTS:
return {
...state,
loading: true,
error: null,
}
case GET_POSTS_SUCCESS:
return {
...state,
loading: false,
data: action.payload.posts,
}
case GET_POSTS_ERROR:
return {
...state,
loading: false,
error: action.error,
}
default:
return state
}
}
액션 타입 지정에서 경로('posts/')를 추가하는 이유는 만약 다른 리듀서를 작성할때 같은 액션타입이 존재한다면 혼동이 생길수 있으므로, 이를 구분하기 위해서 작성한다.
GET_POSTS → 로딩을 True 로 설정하는 액션
GET_POSTS_SUCCESS → 요청이 성공하면, 로딩을 False 로, data 부분을 응답으로 설정하는 액션
GET_POSTS_ERROR → 요청이 실패하면, 에러를 저장하는 액션
액션 생성함수가 아니라 아예 액션 요청과 동시에 distpatch까지 진행하는 코드를 작성했다.
-> 요청이 성공해서 응답이 오면 , 데이터를 reducer 쪽으로 던지는 것이다.
PostContainer는 아래처러 작성한다.
src/pages/Posts/PostsContainer.jsx
import { useSelector, useDispatch } from 'react-redux'
import { Loading } from './../../../../seventhApp/src/components/Common/Loading/style'
function PostsContainer() {
const { data, loading, error } = useSelector((state) => state.posts)
const dispatch = useDispatch()
// 글 가져오는 과정 수행!
useEffect(() => {
dispatch(getPosts())
}, [dispatch])
if (loading) return <Loading />
if (error) return <div>에러발생</div>
if (!data) return null
return <PostsPresenter posts={data} />
}
export default PostsContainer
pages/Posts/PostsPresenter.jsx
```jsx
import React from 'react'
function PostsPresenter({ posts }) {
return (
<div>
{posts.map((post) => (
<p>{post.title}</p>
))}
</div>
)
}
export default PostsPresenter
이제 Provider를 사용해서 main.jsx에 덮어주고 App.jsx 에는 PostsContainer를 리턴하면 된다.
main.jsx
import React from 'react'
import ReactDOM from 'react-dom/client'
import { Provider } from 'react-redux'
import App from './App'
import store from './modules'
ReactDOM.createRoot(document.getElementById('root')).render(
<Provider store={store}>
<React.StrictMode>
<App />
</React.StrictMode>
</Provider>,
)
App.jsx
import PostsContainer from './pages/Posts/PostsContainer'
function App() {
return (
<div className="App">
<PostsContainer />
</div>
)
}
export default App
기존에 Redux 외에 설치해야 했던 각종 패키지 기능을 통합해서, 조금 더 간편하게 Redux 기능을 사용할 수 있도록 도와주는 모듈이다.
기본적으로 비동기 작업을 위한 thunk,saga 관련 기능이 내장되어있고, 액션을 간편하게 명시하기위한 redux-actions 관련 기능 또한 내장되어있다.
요약하자면 쓰는 이유는
yarn add @reduxjs/toolkit react-redux
logging 기능 쓰고 싶으면
yarn add redux-logger
action, reducer 각각 작성하는거 귀찮으면 createSlice
라는 함수가 존재한다.
export const 슬라이스 이름 = createSlice({
name: '리듀서 이름',
초기상태 값,
reducers: {
액션이름: (state, action) => {
액션 dispatch 시 실행될 코드
},
},
});
이렇게 해주면 아래와 같이 .actions 혹은 .reducers 를 통해서 꺼내올수 있게 된다.
슬라이스 이름.actions
슬라이스 이름.reducers
store/slices/counterSlice.js
import { createSlice } from '@reduxjs/toolkit'
import { useDispatch, useSelector } from 'react-redux'
const initialState = {
value: 0,
}
export const counterSlice = createSlice({
name: 'counter',
initialState,
reducers: {
increment: (state) => {
state.value += 1
},
decrement: (state) => {
state.value -= 1
},
},
})
export function useCounter() {
const count = useSelector((state) => state.counter.value)
const dispatch = useDispatch()
return {
count,
dispatch,
}
}
export const { increment, decrement } = counterSlice.actions
export default counterSlice.reducer
이제 컴포넌트에서 불러오는 액션의 위치만 변경해주면 된다.
store/index.js
import { combineReducers, configureStore } from '@reduxjs/toolkit';
import { createLogger } from 'redux-logger';
import counterReducer from './slices/counterSlice';
const logger = createLogger();
const rootReducer = combineReducers({
counter: counterReducer,
});
const store = configureStore({
reducer: rootReducer,
middleware: (getDefaultMiddleware) => getDefaultMiddleware().concat(logger),
});
export default store;
components/Counter.jsx
import { increment, useCounter } from '../store/slices/counterSlice';
export default function Counter() {
const { count, dispatch } = useCounter();
return (
<div>
<p>{count}</p>
<button onClick={() => dispatch(increment())}>
+1
</button>
</div>
);
}
import { createSlice } from '@reduxjs/toolkit';
import { useDispatch, useSelector } from 'react-redux';
const initialState = {
value: 0,
};
export const counterSlice = createSlice({
name: 'counter',
initialState,
reducers: {
increment: (state) => {
state.value = state.value + 1;
},
decrement: (state) => {
state.value = state.value - 1;
},
incrementByAmount: (state, action) => {
state.value = state.value + action.payload.diff;
},
decrementByAmount: (state, action) => {
state.value = state.value - action.payload.diff;
},
},
});
export const { increment, decrement, incrementByAmount, decrementByAmount } = counterSlice.actions;
export function useCounter() {
const count = useSelector((state) => state.counter.value);
const dispatch = useDispatch();
return {
count,
dispatch,
};
}
export default counterSlice.reducer;
components/Counter.jsx
import { decrement, decrementByAmount, increment, incrementByAmount, useCounter } from '../store/slices/counterSlice';
export default function Counter() {
const { count, dispatch } = useCounter();
return (
<div>
<p>{count}</p>
<button onClick={() => dispatch(increment())}>
+1
</button>
<button onClick={() => dispatch(decrement())}>
-1
</button>
<button onClick={() => dispatch(incrementByAmount({diff: 2}))}>
+2
</button>
<button onClick={() => dispatch(decrementByAmount({diff: 2}))}>
-2
</button>
</div>
);
}
redux toolkit 에는 createAsyncThunk
를 사용해서, thunk 를 만들어서 async 요청을 관리할 수 있도록 하는 기능이 있다.
사용하는 방법은 기존에 slice 를 이용하는 방법과 크게 다르지 않다. 방금은 증가, 감소라는 action type 에 대해서 state 가 어떻게 달라질지를 지정했다면, 지금은
각각에 대해서 state 가 어떻게 달라져야할지를 지정해주면 된다.
이전에 만들었던 redux thunk 예제를 slice를 사용해서 만들어보자
import { createAsyncThunk, createSlice } from '@reduxjs/toolkit'
const initialState = {
entities: [],
loading: false,
}
export const getPosts = createAsyncThunk('posts/getPosts', async () => {
const response = await fetch('https://jsonplaceholder.typicode.com/posts')
return await response.json()
})
export const postSlice = createSlice({
name: 'posts',
initialState,
reducers: {},
extraReducers: {
//요청 시작
[getPosts.pending]: (state) => {
state.loading = true
},
//요청 성공
[getPosts.fulfilled]: (state, action) => {
state.loading = false
state.entities = action.payload
},
// 요청 실패
[getPosts.rejected]: (state) => {
state.loading = false
},
},
})
export default postSlice.reducer
store 에도 postReducer를 추가해준다.
store/index.js
import { combineReducers, configureStore } from "@reduxjs/toolkit";
import { createLogger } from "redux-logger";
import counterReducer from "./slices/counterSlice";
import postReducer from "./slices/postSlice";
const logger = createLogger();
const rootReducer = combineReducers({
counter: counterReducer,
posts: postReducer,
});
const store = configureStore({
reducer: rootReducer,
middleware: (getDefaultMiddleware) => getDefaultMiddleware().concat(logger),
});
export default store;
PostList 컴포넌트를 만들고 아래과 같이 useDispatch와 useSelector를 사용한다.
import { useEffect } from "react";
import { useDispatch, useSelector } from "react-redux";
import { getPosts } from "../store/slices/postSlice";
function PostList() {
const dispatch = useDispatch();
const { entities: posts, loading } = useSelector((state) => state.posts);
useEffect(() => {
dispatch(getPosts());
}, []);
if (loading) return <p>Loading...</p>;
return (
<div>
{posts.map((post) => (
<p key={post.id}>{post.title}</p>
))}
</div>
);
}
export default PostList;
createAsyncThunk 의 경우 async 요청의 응답을 따로 createSlice 를 활용해서 관리해줘야 해서, 코드가 늘어날 수 밖에 없다.
그래서 RTK query
라고 해서, createSlice 와 createAsyncThunk 의 기능을 동시에 사용하면서, react-query 와 유사하게 백엔드에서 넘어온 데이터를 관리할 수 있는 기능이 존재한다.
특히나 RTK Query 는 캐싱
관련 기능도 지원하기 때문에, 불필요한 요청을 줄일 수도 있다. 그리고 post 하자마자 자동으로 get 하는 기능까지도 구현할 수 있다.
뿐만 아니라, 프론트 데이터와 api 데이터 관리하는 로직 자체를 분리시킬 수도 있다.
백엔드와의 요청을 통해서 받아오는 데이터 → RTK Query 로 관리
프론트엔드만의 데이터 → createSlice 로 관리
뭔 소리인지 모르겠다. 일단 해보자.
앱을 새로 만들어서 작업한다.
이전에 service key를 .env 에 명시했던 것처럼, 백엔드 주소를 .env에 명시해준다.
VITE_SERVER_URL='https://jsonplaceholder.typicode.com'
이렇게 작성하면 import.meta.env.VITE_SERVER_URL
라고 작성해서 해당 주소값을 가져올수 있다.
api는 createApi
라는 함수를 사용해서 만들 수 있다.
해당 함수 안에, 특정한 주소와 관련한 요청을 보두 작성하는 방식이다.
api/postApi.js
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';
export const api이름 = createApi({
reducerPath: '리듀서이름',
baseQuery: fetchBaseQuery({
baseUrl: import.meta.env.VITE_SERVER_URL,
}),
endpoints: (builder) => ({
요청이름: builder.query({
query: (주소에 넘길 값) => 'api 주소값/(주소에 넘길 값)',
}),
}),
});
export const {
use요청이름Query
} = postApi;
endpoints 에다가 보내고자 하는 요청 이름을 명시하면, 자동으로 use요청이름 Query를 사용할수 있게 된다.
요청을 명시할때 builder.query
라고 작성하게 되면, get요청
을 보낼수 있게 되고
builder.mutation
이라고 작성하면, post혹은 put 요청
등을 보낼수 있게 된다.
예를 들어서, endpoints 쪽에 아래처럼 작성하면 https://jsonplaceholder.typicode.com/posts
로 get요청을 보내는 동작이 만들어 지는 것이다.
getPosts: builder.query({
query: () => 'posts',
}),
그 후에 컴포넌트에서 아래처럼 사용하면, 동작을 수행하면서 자동으로 isLoading, isError, data를 반환해준다. 우리가 직접 isError 등을 정의해줄 필요없이 바로 받아와서 사용해줄수 있는 것이다.
const { data: posts, isLoading, isError } = useGetPostsQuery();
post와 관련한 데이터를 받아올 수 있도록 ,아래처럼 작성한다.
getPosts
라는 endpoint를 만든다.
api/postApi.js
import { createApi,fetchBaseQuery } from '@reduxjs/toolkit/query/react'
export const postApi = createApi({
reducderPath: 'postApi',
baseQuery: fetchBaseQuery({
baseUrl: import.meta.env.VITE_SERVER_URL,
}),
endpoints: (builder) => ({
getPosts: builder.query({
query: () => 'posts',
}),
}),
})
export const { useGetPostsQuery } = postApi
store/index.js
에는 이렇게 작성한다.
import { configureStore } from '@reduxjs/toolkit';
import { setupListeners } from '@reduxjs/toolkit/query';
import { postApi } from '../api/postApi';
const store = configureStore({
reducer: {
[postApi.reducerPath]: postApi.reducer,
},
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware().concat(postApi.middleware),
});
setupListeners(store.dispatch);
export default store
setupListeners
는 서비스 내에서 사용자의 행동이나 요소의 변화를 지켜보다가 요청을 보낼수 있도록 listener를 준비해놓기 위한 함수이다. (refetchOnMount, refetchOnreconnect 기능을 위함)
components/Posts.jsx
는 다음과 같이 작성한다.
import React from 'react'
import { useGetPostsQuery } from '../api/postApi'
function Posts() {
const { data: posts, isLoading, isError } = useGetPostsQuery()
if (isLoading) {
return <div>로딩 중...</div>
}
if (isError || !posts) {
return <div>오류 발생</div>
}
return (
<div>
{posts.map((post) => (
<div key={post.id}>
<p>{post.title}</p>
<p>{post.body}</p>
</div>
))}
</div>
)
}
export default Posts
이제 app.js 는 항상 하던대로 provider로 감싸준다
import Posts from './components/Posts'
import { Provider } from 'react-redux'
import store from './store/index'
function App() {
return (
<Provider store={store}>
<Posts />
</Provider>
)
}
export default App
RTK QUERY는 자동으로 데이터를 캐싱한다. 이미 한번 요청을 보냈다면, 일정시간 동안은 해당 응답 데이터를 재사용한다. (기본 60초)
실제로 그런지 확인해보자.
app.jsx
import React, { useState } from 'react'
import { Provider } from 'react-redux'
import Posts from './components/Posts'
import store from './store/index'
function App() {
const [isClicked, setIsClicked] = useState()
return (
<Provider store={store}>
<button type="button" onClick={() => setIsClicked(!isClicked)}>
보이게하기
</button>
{isClicked ? <Posts /> : null}
</Provider>
)
}
export default App
이렇게 바꾸고, 버튼을 누를 때마다 Posts 컴포넌트의 렌더링 여부가 달라지도록 해준다.
한번 시간을 변경해보자.
postApi 에 KeepUnusedDatafor(컴포넌트가 렌더링 되지 않더라도 몇초동안 데이터를 보관할지)
를 1초로 설정한다.
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'
export const postApi = createApi({
reducderPath: 'postApi',
baseQuery: fetchBaseQuery({
baseUrl: import.meta.env.VITE_SERVER_URL,
}),
endpoints: (builder) => ({
getPosts: builder.query({
query: () => 'posts',
keepUnusedDataFor: 1 // 유지 시간
}),
}),
})
export const { useGetPostsQuery } = postApi
이렇게 해주면, 사용자가 컴포넌트가 1초 이상 보이지 않았다면, 컴포넌트를 보이게 할때마다 다시 요청을 보내는 것을 확인할수 있다.
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';
export const postApi = createApi({
reducerPath: 'postApi',
baseQuery: fetchBaseQuery({
baseUrl: import.meta.env.VITE_SERVER_URL,
}),
refetchOnFocus: true, // 추가
endpoints: (builder) => ({
getPosts: builder.query({
query: () => 'posts',
}),
}),
});
export const {
useGetPostsQuery
} = postApi;
이렇게 해주면 화면에 Focus 할때마다 자동으로 요청을 보낸다.
만약 글 하나만 보고싶으면 어떻게 하지?
postApi 코드의 endpoints 에 getPost를 추가
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'
export const postApi = createApi({
reducerPath: 'postApi',
baseQuery: fetchBaseQuery({
baseUrl: import.meta.env.VITE_SERVER_URL,
}),
refetchOnFocus: true, // 추가
endpoints: (builder) => ({
getPosts: builder.query({
query: () => 'posts',
}),
getPost: builder.query({
query: (postId) => `posts/${postId}`,
}),
}),
})
export const { useGetPostsQuery, useGetPostQuery } = postApi
컴포넌트에서 useGetPostsQuery 대신 useGetPostQuery
를 사용해야한다!
또한 id값을 숫자로 적어넣어야한다.
import React from 'react'
import { useGetPostQuery, useGetPostsQuery } from '../api/postApi'
function Posts() {
const { data: post, isLoading, isError } = useGetPostQuery(3)
if (isLoading) {
return <div>로딩 중...</div>
}
return (
<div>
{/* {posts.map((post) => (
<div key={post.id}>
<p>{post.title}</p>
<p>{post.body}</p>
</div>
))} */}
<div key={post.id}>
<p>{post.title}</p>
<p>{post.body}</p>
</div>
</div>
)
}
export default Posts
쿼리에 매개변수를 받아서 요청을 보내는 방법과, post 요청을 보내는 방법을 살펴본다.
매개변수
는 아래처럼 api에 작성하고,
query: (매개변수) => `매개변수를 활용한 주소 작성`
컴포넌트에서 쿼리 함수 실행 시에 매개변수를 명시해주면 된다.
쿼리함수(매개변수)
get 요청 이외의 요청
의 경우, mutation
이라는 함수를 사용해야한다.
createPost: builder.mutation({ // mutation 이라고 작성
query: ({ data }) => ({ // 요청 시 받을 데이터
url: 'posts',
method: 'POST', // method 방식을 명시
body: data, // body 에 데이터를 명시
}),
}),
api/PostApi
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'
export const postApi = createApi({
reducerPath: 'postApi',
baseQuery: fetchBaseQuery({
baseUrl: import.meta.env.VITE_SERVER_URL,
}),
endpoints: (builder) => ({
getPosts: builder.query({
query: () => 'posts',
}),
getPostById: builder.query({
query: (postId) => `posts/${postId}`, // () 에 매개변수를 명시
}),
createPost: builder.mutation({
// mutation 이라고 작성
query: ({ data }) => ({
// 요청 시 받을 데이터
url: 'posts',
method: 'POST', // method 방식을 명시
body: data, // body 에 데이터를 명시
}),
}),
}),
})
export const { useGetPostsQuery, useGetPostByIdQuery, useCreatePostMutation } = postApi
input을 받아서 post 요청을 보내는 컴포넌트 PostInput
를 제작한다.
import React, { useState } from 'react';
import { useCreatePostMutation } from '../api/postApi';
function PostInput() {
const [postInput, setPostInput] = useState({title: '', body: ''});
const [createPost] = useCreatePostMutation();
const inputChangeHandler = (e) => {
const { name, value } = e.target
setPostInput({...postInput, [name]:value});
};
const submitHandler = () => {
createPost({
data: postInput
})
};
return (
<div>
<input name="title" onChange={inputChangeHandler} />
<input name="body" onChange={inputChangeHandler} />
<button onClick={submitHandler}>
Save
</button>
</div>
);
};
export default PostInput;
createPost 부분에 data를 명시할수 있는 것은 postApi.js에 data를 받아서 요청 보내도록 작성해놨기 때문이다.
app.js에다가 아래처럼 컴포넌트를 추가한다.
import React, { useState } from 'react'
import { Provider } from 'react-redux'
import Posts from './components/Posts'
import store from './store/index'
import Post from './components/Post'
import PostInput from './components/PostInput'
function App() {
const [isClicked, setIsClicked] = useState()
return (
<Provider store={store}>
<PostInput />
<button type="button" onClick={() => setIsClicked(!isClicked)}>
보이게하기
</button>
{isClicked ? <Posts /> : null}
<Post />
</Provider>
)
}
export default App
input 창에 내용을 입력하고 save 버튼을 눌렀을때 상태값이 201이 나온다면 성공이다.
이제 Tag 라는 것을 각 요청 별로 설정해서, post 후 자동 get 요청 보내기를 진행한다.
여기서 Tag는 각 요청별 cache 의 이름을 뜻한다고 생각하면 된다.
이 Tag를 invalidate 한다는 것은, 기존에 요청 보냈던 결과 cache를 없애고, 해당 Tag에 대한 요청을 다시 보내는 것을 말한다.
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'
export const postApi = createApi({
reducerPath: 'postApi',
baseQuery: fetchBaseQuery({
baseUrl: import.meta.env.VITE_SERVER_URL,
}),
endpoints: (builder) => ({
getPosts: builder.query({
query: () => 'posts',
providesTags: [{ type: 'Posts', id: 'LIST' }],
}),
getPostById: builder.query({
query: (postId) => `posts/${postId}`, // () 에 매개변수를 명시
providesTags: (result, error, postId) => [{ type: 'Posts', id: postId }],
}),
createPost: builder.mutation({
// mutation 이라고 작성
query: ({ data }) => ({
// 요청 시 받을 데이터
url: 'posts',
method: 'POST', // method 방식을 명시
body: data, // body 에 데이터를 명시
}),
invalidatesTags: (result) => (result ? [{ type: 'Posts', id: 'LIST' }] : []),
}),
}),
})
export const { useGetPostsQuery, useGetPostByIdQuery, useCreatePostMutation } = postApi
-> createPost 요청을 보내거, 거기에 대해 result가 있을때만 {type: 'Posts',id:'LIST'} 라는 태그를 가진 getPosts 요청을 다시 보내는 것이다.
버튼으로 토글해서 보여지게 햇던 Posts를 아래와 같이 수정하고
import React, { useState } from 'react'
import { Provider } from 'react-redux'
import Posts from './components/Posts'
import store from './store/index'
import Post from './components/Post'
import PostInput from './components/PostInput'
function App() {
const [isClicked, setIsClicked] = useState()
return (
<Provider store={store}>
<PostInput />
<Posts />
<Post />
</Provider>
)
}
export default App
실행해서 input을 입력하고 save를 누르면?
post 요청을 하고 나서 바로 get 요청을 하는 모습을 볼수 있다.