본문 바로가기

프레임워크/React

Redux toolkit

Installation

# NPM
npm install @reduxjs/toolkit

# Yarn
yarn add @reduxjs/toolkit

# or 

npx create-react-app my-app --template redux

ConfigureStore

// Typical example
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

// Full example
const store = configureStore({
  reducer,
  middleware,
  devTools: process.env.NODE_ENV !== 'production',
  preloadedState,
  enhancers: [reduxBatch]
})

Reducer

If a reducer is only one, it is used as the default root reducer:

  const store = configureStore({reducer})

If an object of slice reducers, given as an object like:

{
  reducer:
  {
    users: usersReducers,
    posts: postsReducers
  }
}

configureStore under the hood will automatically create a root reducer combining all the reducers.

createAction

// Typescript signature
function createAction(type, prepareAction?)
// Native redux
const INCREMENT = 'counter/increment'

function increment(amount) {
  return {
    type: INCREMENT,
    payload: amount
  }
}

const action = increment(3)
// { type: 'counter/increment', payload: 3 }
// With Redux 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'

Note action typing style counter/increment which keeps consistent with objects created by createSlice.

By default, increment accepts a single argument that is to be passed in to payload. To customize the contents of payload to pass in rather than the single value, a "prepare" callback function can be passed in as second argument as below:

import v4 from 'uuid/v4'

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

console.log(addTodo('Write more docs'))
/**
 * {
 *   type: 'todos/add',
 *   payload: {
 *     text: 'Write more docs',
 *     id: '1b9d6bcd-bbfd-4b2d-9b5d-ab8dfbbd4bed',
 *     createdAt: '2019-10-03T07:53:36.581Z'
 *   }
 * }
 **/

createReducer

A navtive Redux reducer looks as below:

function counterReducer(state = 0, action) {
  switch (action.type) {
    case 'increment':
      return state + action.payload
    case 'decrement':
      return state - action.payload
    default:
      return state
  }
}

where all switch cases need to be hardcopied; boiler-plate-y and error-prone.

createReducer with the signature below provides a more efficient API.

// Typescript signature
export function createReducer<S>(
  initialState: S,
  mapOrBuilderCallback:
    | CaseReducers<S, any>
    | ((builder: ActionReducerMapBuilder<S>) => void)
): Reducer<S> {}
// The native Redux example above is equivalent to 
  const counterReducer = createReducer(0, {
    increment: (state, action) => state + action.payload,
    decrement: (state, action) => state - action.payload
  })

As shown in typescript definition for createReducer, a builder callback function can be given instead of an actions-containing Reducer object, i.e., CaseReducers. This is relevant when typescript is used and not covered herein yet.

Combined with createAction, it can be written as below:

const increment = createAction('increment')
const decrement = createAction('decrement')

const counterReducer = createReducer(0, {
  [increment]: (state, action) => state + action.payload,
  [decrement.type]: (state, action) => state - action.payload
})

Note that the keys of CaseReducers are each given in [] which is called "computed property" syntax introduced in ES6. With this, it automatically calls toString method which returns the value in action.type. Therefore, the following are equivalent to each other:

[increment]: (state, action) => state + action.payload

and

[increment.type]: (state, action) => state + action.payload

State Mutation/Updates

To keep the immutability of states, spread operator is typically used when updating states upon action.

const addTodo = createAction('todos/add')
const toggleTodo = createAction('todos/toggle')

const todosReducer = createReducer([], {
  [addTodo]: (state, action) => {
    const todo = action.payload
    return [...state, todo]
  },
  [toggleTodo]: (state, action) => {
    const index = action.payload
    const todo = state[index]
    return [
      ...state.slice(0, index),
      { ...todo, completed: !todo.completed }
      ...state.slice(index + 1)
    ]
  }
})

Another way to achieve this that is viable when using createReducer is to use native javascript methods such as push as below:

const addTodo = createAction('todos/add')
const toggleTodo = createAction('todos/toggle')

const todosReducer = createReducer([], {
  [addTodo]: (state, action) => {
    // This push() operation gets translated into the same
    // extended-array creation as in the previous example.
    const todo = action.payload
    state.push(todo)
  },
  [toggleTodo]: (state, action) => {
    // The "mutating" version of this case reducer is much
    //  more direct than the explicitly pure one.
    const index = action.payload
    const todo = state[index]
    todo.completed = !todo.completed
  }*
})

immer, the built-in library in createReducer, guarantees under the hood that new state gets passed in as new object copy.

Note that when immer-style mutation is practces, it shall not return

createSlice

// Typescript signature
function createSlice({
    // An object of "case reducers". Key names will be used to generate actions.
    reducers: Object<string, ReducerFunction | ReducerAndPrepareObject>
    // The initial state for the reducer
    initialState: any,
    // A name, used in action types
    name: string,
    // An additional object of "case reducers". Keys should be other action types.
    extraReducers?:
    | Object<string, ReducerFunction>
    | ((builder: ActionReducerMapBuilder<State>) => void)
})

This is the most powerful/magical API as it *" accepts an initial state, an object full of reducer functions, and a "slice name", and automatically generates action creators and action types that correspond to the reducers and state."*, due to which it is the most confusing API as a lot of things happen under the hood.

Three arguments are mandatory: reducers, initialState, and name; extraReducers is optional.

import { createSlice } from '@reduxjs/toolkit'

const todosSlice = createSlice({
  name: 'todos',
  initialState: [],
  reducers: {
    addTodo(state, action) {
      const { id, text } = action.payload
      state.push({ id, text, completed: false })
    },
    toggleTodo(state, action) {
      const todo = state.find(todo => todo.id === action.payload)
      if (todo) {
        todo.completed = !todo.completed
      }
    }
  }
})

export const { addTodo, toggleTodo } = todosSlice.actions

export default todosSlice.reducer

Note reducers written in ES6 syntax can be re-written in ES5 style using arrow functions as below:

  reducers: {
    addTodo: (state, action) => {
      const { id, text } = action.payload
      state.push({ id, text, completed: false })
    },
    toggleTodo: (state, action) => {
      const todo = state.find(todo => todo.id === action.payload)
      if (todo) {
        todo.completed = !todo.completed
      }
    }
  }

Based on name, initialState, and reducers, createSlice returns the object as below:

{
  name: "todos",
  reducer: (state, action) => newState,
  actions: {
    addTodo: (payload) => ({type: "todos/addTodo", payload}),
    toggleTodo: (payload) => ({type: "todos/toggleTodo", payload})
  },
  caseReducers: {
    addTodo: (state, action) => newState,
    toggleTodo: (state, action) => newState,
  }
}

Note it is todosSlice.reducer that gets exported for further use.

const incrementBy = createAction<number>('incrementBy')
const decrementBy = createAction<number>('decrementBy')

const counter = createSlice({
  name: 'counter',
  initialState: 0 as number,
  reducers: {
    increment: state => state + 1,
    decrement: state => state - 1,
    multiply: {
      reducer: (state, action: PayloadAction<number>) => state * action.payload,
      prepare: (value: number) => ({ 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
    })
  }
})

As shown above, createAction is carried out for all given reducers without explicit action declaration, e.g., increment, decrement.
multiply for which a custom payload is to be defined, reducer and prepare keys are used.

let nextTodoId = 0

const todosSlice = createSlice({
  name: 'todos',
  initialState: [],
  reducers: {
    addTodo: {
      reducer(state, action) {
        const { id, text } = action.payload
        state.push({ id, text, completed: false })
      },
      prepare(text) {
        return { payload: { text, id: nextTodoId++ } }
      }
    }
  }
}

extraReducers

... to be updated.

References

https://redux-toolkit.js.org/

'프레임워크 > React' 카테고리의 다른 글

VS Code ES7 React/Redux/React-Native/JS snippets  (0) 2020.06.07