[포스코x코딩온] KDT-Web-8 14주차 회고1 Redux-Toolkit

Yunes·2023년 10월 9일
0

[포스코x코딩온]

목록 보기
35/47
post-thumbnail

Redux Toolkit 시작하기

Redux Toolkit 패키지는 redux 로직을 쓰기 위한 표준 방법으로서 고안되었다. 이는 Redux 의 3가지 일반적인 문제를 해결하기 위해 만들어졌다.

  • Redux store 를 만드는게 너무 복잡하다.
  • Redux 가 유용한 작업을 수행하려면 많은 패키지를 추가해야 한다.
  • Redux 에는 너무 많은 boilerplate code 가 필요하다.

Redux Toolkit 은 RTK Query 라 부르는 강력한 데이터 가져오기 및 캐싱기능을 갖고 있다.

설치 방법

npm i @reduxjs/toolkit

Redux Toolkit APIs

configureStore()

단순화된 구성 옵션과 좋은 기본값을 제공하기 위해 createStore 를 래핑한다. 이 API 는 자동으로 slice reducer 를 결합하고 제공하는 Redux 미들웨어를 추가하고, redux-thunk 를 기본으로 포함하며 Redux DevTools Extention 을 사용 가능하다. redux store 를 만드는 표준 메서드이다.

purpose and behavior

  • slice reducer 들을 root reducer 로 결합
  • 개발용 확인을 위한 미들웨어처럼 부수효과 미들웨어, thunk 미들웨어를 폼함하는 middleware enhancer 를 생성
  • Redux DevTools enhancer 를 추가하고 enhancer 들을 함께 구성
  • createStore 를 호출

예시

import { configureStore } from '@reduxjs/toolkit'

import rootReducer from './reducers'

const store = configureStore({ reducer: rootReducer })
// The store now has redux-thunk added and the Redux DevTools Extension is turned on

혹은 reducer 가 여러개 있을때 객체로 묶어줄 수 있다.

const store = configureStore({ reducer: {
	users: usersReducer,
  	posts: postsReducer
} })

createReducer()

스위치문 작성 대신 Case Reducer 함수에 액션 타입의 lookup table 을 제공할 수 있다. 추가로 자동으로 Immer 라이브러리를 사용하여 state.todos[3].completed = true 같은 immutable update 를 normal mutative code 로 사용할 수 있게 해준다.

import { createAction, createReducer } from '@reduxjs/toolkit'

const increment = createAction('counter/increment')
const decrement = createAction('counter/decrement')
const incrementByAmount = createAction('counter/incrementByAmount')

const initialState = { value: 0 }

const counterReducer = createReducer(initialState, (builder) => {
  builder
    .addCase(increment, (state, action) => {
      state.value++
    })
    .addCase(decrement, (state, action) => {
      state.value--
    })
    .addCase(incrementByAmount, (state, action) => {
      state.value += action.payload
    })
})

builder method - builder.addCase

하나의 액션 타입을 다루는 reducer case 를 추가한다.
builder.addMatcher, builder.addDefaultCase 보다 앞에서 호출되어야 한다.

parameter

  • actionCreator : plain action type 문자열이거나 createAction 을 통해 생성된 액션 생성자
  • reducer : 실질적인 reducer 함수

builder method - builder.addMatcher

action.type 속성만이 아니라 자체적인 함수를 통해 들어오는 액션들을 일치시킬수 있다.
builder.addMatcher 보다 뒤에서, builder.addDefaultCase 보다 앞에서 호출되어야 한다.

parameter

  • matcher : matcher 함수, TS 에서는 type 을 예측 가능한 함수가 되어야 한다.
  • reducer : 실질적인 reducer 함수
import { createAction, createReducer } from '@reduxjs/toolkit'

const initialState = {}
const resetAction = createAction('reset-tracked-loading-state')

function isPendingAction(action) {
  return action.type.endsWith('/pending')
}

const reducer = createReducer(initialState, (builder) => {
  builder
    .addCase(resetAction, () => initialState)
    // matcher can be defined outside as a type predicate function
    .addMatcher(isPendingAction, (state, action) => {
      state[action.meta.requestId] = 'pending'
    })
    .addMatcher(
      // matcher can be defined inline as a type predicate function
      (action) => action.type.endsWith('/rejected'),
      (state, action) => {
        state[action.meta.requestId] = 'rejected'
      }
    )
    // matcher can just return boolean and the matcher can receive a generic argument
    .addMatcher(
      (action) => action.type.endsWith('/fulfilled'),
      (state, action) => {
        state[action.meta.requestId] = 'fulfilled'
      }
    )
})

builder method - builder.addDefaultCase

만약 아무 리듀서가 주어지지 않거나 이 액션에 일치하는 리듀서가 없을경우에 대한 기본 리듀서를 추가한다.

parameter

  • reducer : 기본 리듀서 함수
import { createReducer } from '@reduxjs/toolkit'
const initialState = { otherActions: 0 }
const reducer = createReducer(initialState, (builder) => {
  builder
    // .addCase(...)
    // .addMatcher(...)
    .addDefaultCase((state, action) => {
      state.otherActions++
    })
})

createAction()

주어진 액션 타입 문자열에 대해 액션 생성자 함수를 생성한다. 이 함수는 자체적으로 toString() 을 갖고 있어서 type constant 대신 사용할 수 있다.

function createAction(type, prepareAction?)
import { createAction } from '@reduxjs/toolkit'

const increment = createAction('counter/increment')

let action = increment()
// { type: 'counter/increment' }

action = increment(3)
// returns { type: 'counter/increment', payload: 3 }

console.log(increment.toString())
// 'counter/increment'

console.log(`The action type is: ${increment}`)
// 'The action type is: counter/increment'


////


const addTodo = createAction('todos/add', function prepare(text) {
  return {
    payload: {
      text,
      id: nanoid(),
      createdAt: new Date().toISOString(),
    },
  }
})

⭐️ createSlice()

리듀서 함수의 객체, 슬라이스 이름, 초기 상태 값을 받아들이고, 해당 액션 생성자와 액션 유형을 사용하여 슬라이스 리듀서를 자동으로 생성한다.

내부적으로 createAction 와 createReducer 를 사용하기에 이 API 역시 Immer 를 사용하여 mutating 방식으로 immutable update 를 할 수 있다.

import { createSlice } from '@reduxjs/toolkit'

const initialState = { value: 0 }

const counterSlice = createSlice({
  name: 'counter',
  initialState,
  reducers: {
    increment(state) {
      state.value++
    },
    decrement(state) {
      state.value--
    },
    incrementByAmount(state, action) {
      state.value += action.payload
    },
  },
})

export const { increment, decrement, incrementByAmount } = counterSlice.actions
export default counterSlice.reducer

parameter

function createSlice({
    // A name, used in action types
    name: string,
    // The initial state for the reducer
    initialState: any,
    // An object of "case reducers". Key names will be used to generate actions.
    reducers: Object<string, ReducerFunction | ReducerAndPrepareObject>
    // A "builder callback" function used to add more reducers, or
    // an additional object of "case reducers", where the keys should be other
    // action types
    extraReducers?:
    | Object<string, ReducerFunction>
    | ((builder: ActionReducerMapBuilder<State>) => void)
})
  • reducers 를 통해 전달된 객체는 createReducer 로 전달되어 상태를 mutate 하는 것을 안전하게 할 수 있다.

  • extraReducer 를 사용하는 예시

import { createAction, createSlice } from '@reduxjs/toolkit'
const incrementBy = createAction('incrementBy')
const decrement = createAction('decrement')

function isRejectedAction(action) {
  return action.type.endsWith('rejected')
}

createSlice({
  name: 'counter',
  initialState: 0,
  reducers: {},
  extraReducers: (builder) => {
    builder
      .addCase(incrementBy, (state, action) => {
        // action is inferred correctly here if using TS
      })
      // You can chain calls, or have separate `builder.addCase()` lines each time
      .addCase(decrement, (state, action) => {})
      // You can match a range of action types
      .addMatcher(
        isRejectedAction,
        // `action` will be inferred as a RejectedAction due to isRejectedAction being defined as a type guard
        (state, action) => {}
      )
      // and provide a default case if no other handlers matched
      .addDefaultCase((state, action) => {})
  },
})
  • 전체 사용 예시
import { createSlice, createAction } from '@reduxjs/toolkit'
import { createStore, combineReducers } from 'redux'

const incrementBy = createAction('incrementBy')
const decrementBy = createAction('decrementBy')

const counter = createSlice({
  name: 'counter',
  initialState: 0,
  reducers: {
    increment: (state) => state + 1,
    decrement: (state) => state - 1,
    multiply: {
      reducer: (state, action) => state * action.payload,
      prepare: (value) => ({ payload: value || 2 }), // fallback if the payload is a falsy value
    },
  },
  // "builder callback API", recommended for TypeScript users
  extraReducers: (builder) => {
    builder.addCase(incrementBy, (state, action) => {
      return state + action.payload
    })
    builder.addCase(decrementBy, (state, action) => {
      return state - action.payload
    })
  },
})

const user = createSlice({
  name: 'user',
  initialState: { name: '', age: 20 },
  reducers: {
    setUserName: (state, action) => {
      state.name = action.payload // mutate the state all you want with immer
    },
  },
  // "map object API"
  extraReducers: {
    [counter.actions.increment]: (
      state,
      action /* action will be inferred as "any", as the map notation does not contain type information */
    ) => {
      state.age += 1
    },
  },
})

const reducer = combineReducers({
  counter: counter.reducer,
  user: user.reducer,
})

const store = createStore(reducer)

store.dispatch(counter.actions.increment())
// -> { counter: 1, user: {name : '', age: 21} }
store.dispatch(counter.actions.increment())
// -> { counter: 2, user: {name: '', age: 22} }
store.dispatch(counter.actions.multiply(3))
// -> { counter: 6, user: {name: '', age: 22} }
store.dispatch(counter.actions.multiply())
// -> { counter: 12, user: {name: '', age: 22} }
console.log(`${counter.actions.decrement}`)
// -> "counter/decrement"
store.dispatch(user.actions.setUserName('eric'))
// -> { counter: 12, user: { name: 'eric', age: 22} }

⭐️ createAsyncThunk

액션 타입 문자열과 프로미스를 반환하는 함수를 받고 그 프로미스에 기반하여 pending, fulfilled, rejected 액션 타입을 dispatch 하는 thunk 를 생성한다.

비동기 요청 lifecycle 을 처리하기 위한 표준 권장 접근 방식을 추상화한다.

가져오고 있는 데이터가 무엇인지, 로딩중인 상태를 어떻게 추적할지, 반환된 데이터를 어떻게 처리할 지 모르기 때문에 어떤 리듀서 함수도 생성하지 않는다. 앱에 적합한 로직에 따라 이 액션들 로딩중인 상태들을 다루는 자체적인 리듀서로직을 만들어야 한다.

  • 예시 코드
import { createAsyncThunk, createSlice } from '@reduxjs/toolkit'
import { userAPI } from './userAPI'

// First, create the thunk
const fetchUserById = createAsyncThunk(
  'users/fetchByIdStatus',
  async (userId: number, thunkAPI) => {
    const response = await userAPI.fetchById(userId)
    return response.data
  }
)

interface UsersState {
  entities: []
  loading: 'idle' | 'pending' | 'succeeded' | 'failed'
}

const initialState = {
  entities: [],
  loading: 'idle',
} as UsersState

// Then, handle actions in your reducers:
const usersSlice = createSlice({
  name: 'users',
  initialState,
  reducers: {
    // standard reducer logic, with auto-generated action types per reducer
  },
  extraReducers: (builder) => {
    // Add reducers for additional action types here, and handle loading state as needed
    builder.addCase(fetchUserById.fulfilled, (state, action) => {
      // Add user to the state array
      state.entities.push(action.payload)
    })
  },
})

// Later, dispatch the thunk as needed in the app
dispatch(fetchUserById(123))

parameter

RTK Query data fetching API 는 리덕스 앱을 위한 데이터 가져오기와 캐싱 해결책으로 만들어졌기 때문에 어떤 thunk 나 데이터를 가져오는 것을 관리하기 위한 리듀서를 작성할 필요를 제거해준다.

액션 타입 문자열

비동기 요청의 생명주기를 나타내는 추가적인 액션 타입 상수를 생성하는데 사용되는 문자열이다.

만약 type 이 users/requestStatus 라면 이는 다음 3가지 액션 타입을 생성한다.

  • pending : users/requestStatus/pending
  • fulfilled : users/requestStatus/fulfilled
  • rejected : users/requestStatus/rejected

payloadCreator 콜백

어떤 비동기 로직의 결과를 포함하는 프로미스를 반환해야 하는 콜백 함수이다. 이는 또한 값을 동기적으로 반환할 수도 있다. 만약 오류가 생긴다면 Error 인스턴스나 에러 메세지 등을 포함하는 plain value 를 포함하는 rejected promise 를 반환한다.

payloadCreator 는 2개의 인자를 갖는다.

  • args : 단일 값으로 여러개의 값을 전달하고 싶으면 객체로 전달한다.

dispatch(fetchUsers({status: 'active', sortBy: 'name'}))

  • thunkAPI : Redux thunk 함수로 전달되는 모든 파라미터를 포함하는 객체이다.

    • dispatch
    • getState
    • extra
    • requestId
  • 전체 사용 예시 코드

import { createAsyncThunk, createSlice } from '@reduxjs/toolkit'
import { userAPI, User } from './userAPI'

const fetchUserById = createAsyncThunk<User, string, {
    state: { users: { loading: string, currentRequestId: string } }
}> (  
  'users/fetchByIdStatus',
  async (userId: string, { getState, requestId }) => {
    const { currentRequestId, loading } = getState().users
    if (loading !== 'pending' || requestId !== currentRequestId) {
      return
    }
    const response = await userAPI.fetchById(userId)
    return response.data
  }
)

const usersSlice = createSlice({
  name: 'users',
  initialState: {
    entities: [],
    loading: 'idle',
    currentRequestId: undefined,
    error: null,
  },
  reducers: {},
  extraReducers: (builder) => {
    builder
      .addCase(fetchUserById.pending, (state, action) => {
        if (state.loading === 'idle') {
          state.loading = 'pending'
          state.currentRequestId = action.meta.requestId
        }
      })
      .addCase(fetchUserById.fulfilled, (state, action) => {
        const { requestId } = action.meta
        if (
          state.loading === 'pending' &&
          state.currentRequestId === requestId
        ) {
          state.loading = 'idle'
          state.entities.push(action.payload)
          state.currentRequestId = undefined
        }
      })
      .addCase(fetchUserById.rejected, (state, action) => {
        const { requestId } = action.meta
        if (
          state.loading === 'pending' &&
          state.currentRequestId === requestId
        ) {
          state.loading = 'idle'
          state.error = action.error
          state.currentRequestId = undefined
        }
      })
  },
})

const UsersComponent = () => {
  const { entities, loading, error } = useSelector((state) => state.users)
  const dispatch = useDispatch()

  const fetchOneUser = async (userId) => {
    try {
      const user = await dispatch(fetchUserById(userId)).unwrap()
      showToast('success', `Fetched ${user.name}`)
    } catch (err) {
      showToast('error', `Fetch failed: ${err.message}`)
    }
  }

  // render UI here
}

RTK Query

RTK Query 는 데이터 가져오기 및 캐싱의 사용 사례를 해결하기 위한 목적으로 만들어졌고 앱을 위한 API 인터페이스 레이어를 정의하는 강력한 toolset 이다. RTK Query 를 통해 웹 애플리케이션에서 데이터를 로드하는 일반적인 경우를 단순화하기에 데이터 가져오기 및 캐싱을 직접 작성할 필요가 없다.

import { createApi } from '@reduxjs/toolkit/query'

/* React-specific entry point that automatically generates
   hooks corresponding to the defined endpoints */
import { createApi } from '@reduxjs/toolkit/query/react'

RTK Query APIs

createApi()

RTK Query 기능의 핵심이다. 종단점 집합을 정의할 수 있고 일련의 종단점으로부터 어떻게 데이터를 가져오고 변형시킬 수 있는지를 포함하여 어떻게 데이터를 가져오는지를 설명한다. 대부분의 경우 base URL 당 하나의 API slice 를 사용한다.

// Need to use the React-specific entry point to allow generating React hooks
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'

// Define a service using a base URL and expected endpoints
export const pokemonApi = createApi({
  reducerPath: 'pokemonApi',
  baseQuery: fetchBaseQuery({ baseUrl: 'https://pokeapi.co/api/v2/' }),
  endpoints: (builder) => ({
    getPokemonByName: builder.query({
      query: (name) => `pokemon/${name}`,
    }),
  }),
})

// Export hooks for usage in function components, which are
// auto-generated based on the defined endpoints
export const { useGetPokemonByNameQuery } = pokemonApi

parameter

baseQuery

만약 queryFn 옵션이 명시되지 않았다면 각각의 종단점에서 사용된다. RTK Query 는 fetchBaseQuery 라는 경량화된 fetch 래퍼를 제공한다.

endpoints

서버에 수행하고 싶은 작업 집합이다. builder 문법을 사용하여 객체를 정의한다. query 와 mutation 이라는 두가지 타입의 endpoint 가 있다.

  • query : 선택한 데이터 가져오기 라이브러리를 사용하여 쿼리 작업을 수행할 수 있지만 일반적인 권장사항은 데이터를 검색하는 요청에만 쿼리를 사용하는 것이다. 서버의 데이터를 변경하거나 캐시를 무효화할 수 있는 모든 경우에는 mutation 을 사용해야 한다.
  • mutation : 데이터 업데이트를 서버에 보내고 변경 사항을 로컬 캐시에 적용하는 데 사용된다.

추가 예시

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

const api = createApi({
  baseQuery: fetchBaseQuery({ baseUrl: '/' }),
  tagTypes: ['Posts'],
  endpoints: (build) => ({
    getPosts: build.query({
      query: () => 'posts',
      providesTags: (result) =>
        result ? result.map(({ id }) => ({ type: 'Posts', id })) : [],
    }),
    addPost: build.mutation({
      query: (body) => ({
        url: `posts`,
        method: 'POST',
        body,
      }),
      invalidatesTags: ['Posts'],
    }),
  }),
})

// Auto-generated hooks
export const { useGetPostsQuery, useAddPostMutation } = api

// Possible exports
export const { endpoints, reducerPath, reducer, middleware } = api
// reducerPath, reducer, middleware are only used in store configuration
// endpoints will have:
// endpoints.getPosts.initiate(), endpoints.getPosts.select(), endpoints.getPosts.useQuery()
// endpoints.addPost.initiate(), endpoints.addPost.select(), endpoints.addPost.useMutation()
// see `createApi` overview for _all exports_

fetchBaseQuery()

요청을 단순화하는 것을 목표로 하는 작은 wrapper 이다. HTTP 요청을 단순화하기 위해 사용한다. axios, superagent 등을 완전히 대체하는 것은 아니나 대부분의 HTTP 요청 요구사항을 충족한다.

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

export const pokemonApi = createApi({
  // Set the baseUrl for every endpoint below
  baseQuery: fetchBaseQuery({ baseUrl: 'https://pokeapi.co/api/v2/' }),
  endpoints: (builder) => ({
    getPokemonByName: builder.query({
      // Will make a request like https://pokeapi.co/api/v2/pokemon/bulbasaur
      query: (name) => `pokemon/${name}`,
    }),
    updatePokemon: builder.mutation({
      query: ({ name, patch }) => ({
        url: `pokemon/${name}`,
        // When performing a mutation, you typically use a method of
        // PATCH/PUT/POST/DELETE for REST endpoints
        method: 'PATCH',
        // fetchBaseQuery automatically adds `content-type: application/json` to
        // the Headers and calls `JSON.stringify(patch)`
        body: patch,
      }),
    }),
  }),
})

참고

  • 파라미터중 preHeaders 는 선택적으로 사용하는데 모든 요청에 헤더를 삽입할 수 있도록 한다.
type prepareHeaders = (
  headers: Headers,
  api: {
    getState: () => unknown
    extra: unknown
    endpoint: string
    type: 'query' | 'mutation'
    forced: boolean | undefined
  }
) => Headers | void
import { fetchBaseQuery } from '@reduxjs/toolkit/query'

const baseQuery = fetchBaseQuery({
  baseUrl: '/',
  prepareHeaders: (headers, { getState }) => {
    const token = getState().auth.token

    // If we have a token set in state, let's assume that we should be passing it.
    if (token) {
      headers.set('authorization', `Bearer ${token}`)
    }

    return headers
  },
})

결론

store, action 등을 따로 관리할 필요가 없으니 redux 를 사용시에는 어지간하면 redux toolkit 의 createSlice 를 사용할 것 같다. HTTP 요청도 같이 처리할 테니 redux 에서 createSlice, createAsyncThunk 만 잘 활용한다면 어지간한 기능은 다 잘 쓸 수 있을 것 같고 추가로 RTK Query 를 활용해보자.

profile
미래의 나를 만들어나가는 한 개발자의 블로그입니다.

0개의 댓글