這篇教學介紹Redux如何用正規化(normalization)的方式,儲存從API取得的遠端資料。
目錄
TL;DR
Redux如同前端的in-memory小型資料庫,因此存資料時,會推薦存成正規化(normalized)的形式,可以確保相同的資料只會有一份,減少資料更新時同步的錯誤。
打API時建議使用thunk的形式。
可以透過normalizr
將API的回應轉換成normalized的形式。
Normalize the State Shape
假設我們今天要做一個todo list的應用,API response以array的形式回傳:
{
todos: [
{
id: 1,
text: 'hey',
completed: true,
},
{
id: 2,
text: 'ho',
completed: true,
},
{
id: 3,
text: 'let’s go',
completed: false,
}
]
}
使用Redux來管理資料的情況,要如何規劃儲存的資料結構呢?
假設每筆todo.id
都是唯一的,我們可以將todos
reducer分成兩部分:
byId
: 以id
為key的todos
的集合allIds
:id
的array
用combineReducer
API組合reducer如下:
const todos = combineReducer({
byId,
allIds
})
byId
reducer
用來存放todo
實體。實際上的資料結構是一個object,以todo.id
為key,todo
實體作為value。
在ADD_TODO
和TOGGLE_TODO
時更新todo
實體:
const byId = (state = {}, action) => {
switch(action.type) {
case 'ADD_TODO':
case 'TOGGLE_TODO':
return {
...state,
[action.id]: todo(state[action.id], action) // Update the entity
}
default:
return state
}
}
其中todo
reducer會在ADD_TODO
時新增一筆對應的todo
,而TOGGLE_TODO
時更改對應的todo.completed
:
const todo = (state = {}, action) => {
switch(action.type) {
case 'ADD_TODO':
return {
id: action.id,
text: action.text,
completed: false
}
case 'TOGGLE_TODO':
return {
...state,
completed: !state.completed
}
default:
return state
}
}
allIds
reducer
用來存放id
的array。
當ADD_TODO
時,向array新增一筆todo.id
:
const allIds = (state = [], action) => {
switch(action.type) {
case 'ADD_TODO':
return [...state, action.id]
default:
return state
}
}
Why Normalized State?
我們也可以將API回傳的Array原封不動存進Redux state裡面,為什麼要用上面那麼麻煩的作法呢?
考慮一個情境:假設現在todo list要加進filter的功能,分別有all
, active
, completed
三種filter,那我們會需要三個list存三種filter的結果,其中不同的list可能包含重複的實體(例如:active
的結果必然出現在all
的結果之中)。這種設計下,假設某一筆Todo實體更新的話,必然要對所有list遍歷更新對應的實體,既沒有效率又容易忘記更新。
Redux的中心思想是:相同的資料只有一份,並且集中管理。如果我們用normalized的形式來存這三個list,就會變成用一個mapping table存todo
的實體,及三個list分別存filter過後的id
array。
資料放在實體的集合中,可以確保相同的資料只有一份。如果實體改變了,只需要更新實體的集合一次,每個list靠著id
就能夠對應到修改過後的實體。
Reducer with Filter
Reducer實作如下:
const todos = combineReducers({
byId,
idsByFilter
})
byId
用來存放todo
實體:
const byId = (state = {}, action) => {
switch (action.type) {
case 'RECEIVE_TODOS':
const nextState = { ...state }
action.response.forEach(todo => {
nextState[todo.id] = todo
})
return nextState
default:
return state;
}
};
export default byId
export const getTodo = (state, id) => state[id] // Selector
idsByFilter
用來存放三個filtered list:
const idsByFilter = combineReducers({
all: createList('all'),
active: createList('active'),
completed: createList('completed')
})
createList
回傳list reducer(須檢查action.filter
):
const createList = (filter) => {
return (state = [], action) => {
if (action.filter !== filter) {
return state
}
switch (action.type) {
case 'RECEIVE_TODOS':
return action.response.map(todo => todo.id)
default:
return state
}
}
}
export default createList
export const getIds = state => state // Selector
最後export getVisibleTodos
selector,其中會用到getIds
把filter對應的id
array取出,再分別對每個id
用getTodo
selector取出對應的todo
實體:
import byId, * as fromById from './byId'
import createList, * as fromList from './createList'
const todos = combineReducers({
byId,
idsByFilter
})
export const getVisibleTodos = (state, filter) => {
const ids = fromList.getIds(state.idsByFilter[filter])
return ids.map(id => fromById.getTodo(state.byId, id))
};
Fetch Data from API
如果要打API拿資料,可以寫一個fetchData()
,在componentDidMount()
和componentDidUpdate()
裡面呼叫:
class VisibleTodoList extends Component {
fetchData() {
const {
filter,
requestTodos,
receiveTodos,
fetchTodos
} = this.props
requestTodos(filter)
fetchTodos(filter).then(todos => {
receiveTodos(filter, todos)
})
}
componentDidMount() {
this.fetchData()
}
// filter改變時重新取資料
componentDidUpdate(prevProps) {
if (this.props.filter !== prevProps.filter) {
this.fetchData()
}
}
render() {
const { toggleTodo, ...rest } = this.props
return (
<TodoList
{...rest}
onTodoClick={toggleTodo}
/>
)
}
}
用react-redux
的connect
API把資料餵進Component
:
import * as actions from '../actions'
class VisibleTodoList extends Component {
...
}
VisibleTodoList = connect(
mapStateToProps,
actions
)(VisibleTodoList)
注意如果mapDispatchToProps
參數傳的是actions
物件,和action同名的方法會被注入至this.props
,亦即呼叫this.props.fetchTodos
可以dispatch
fetchTodos
action。
Thunk
打API動作通常有很多步驟,而且經常是非同步的。例如打API時先dispatch
開始的actionrequestTodos
,讓頁面狀態變成loading,dispatch
打API的actionfetchTodos
,等到API回傳結果後,再dispatchreceiveTodos
來更新結果:
class VisibleTodoList extends Component {
fetchData() {
const {
filter,
requestTodos,
receiveTodos,
fetchTodos
} = this.props
requestTodos(filter)
fetchTodos(filter).then(todos => {
receiveTodos(filter, todos)
})
}
...
}
這一連串動作一定得一起使用,容易漏掉一連串動作之中的單獨一步。如果能夠將這一組動作抽象成一個單一的action,比較不容易出錯,也更容易復用。
這種抽象的方法稱為thunk。
Thunk就是一個回傳function的function,在redux的使用情境下,可以更精確定義成以下形式的function:
(...args) => (dispatch, getState) => { // Do something ... }
舉例來說,如果把一連串的動作都抽象在一個fetchTodos
的thunk內,大致如下:
export const fetchTodos = (filter) => (dispatch, getState) => {
// 判斷是否正在打API
if (getIsFetching(getState(), filter)) {
return Promise.resolve()
}
// 改變狀態成為loading
dispatch({
type: 'FETCH_TODOS_REQUEST',
filter
})
// 打API,根據回傳值成功或失敗分別做處理
return api.fetchTodos(filter).then(
response => {
dispatch({
type: 'FETCH_TODOS_SUCCESS',
filter,
response
})
},
error => {
dispatch({
type: 'FETCH_TODOS_FAILURE',
filter,
message: error.message || 'Something went wrong!'
})
}
)
}
Redux Thunk Middleware
Redux只能處理plain object形式的action,所以如果要處理thunk必須要用專屬的middleware。
thunk middleware的核心大致上可以濃縮成以下幾行:
const thunk = store => next => action =>
typeof action === 'function' ?
action(store.dispatch, store.getState) :
next(action)
Thunk middleware要處理的事情大致上是:如果action是一個function的話就執行,並且把dispatch
跟getState
作為參數餵進thunk。如此一來thunk內部能夠使用dispatch
,能夠自行決定各種同步/非同步的流程,以及何時要dispatch
,也能根據當下store的資料做流程控制。
值得注意的是Thunk middleware中任何被dispatch
的action可以從頭到尾跑過一次middleware chain,所以在thunk裡面再dispatch
thunk也沒問題喔,因為會被thunk middleware處理到。(關於dispatch
的更詳細的說明可以參考Redux Middleware Chain。)
Using normalizr
我們從fetchTodo
API得到的repsonse會是以array的形式返回:
// Before
[
{
"id": "ee05070a-eda5-4fcc-a685-a5cf4be6dc60",
"text": "hey",
"completed": true
},
{
"id": "0bce8ddb-4f8a-44cf-9050-203ecdbb0d93",
"text": "ho",
"completed": true
},
{
"id": "91451381-2fd6-4f81-b316-cc93f927c34a",
"text": "let’s go",
"completed": false
}
]
而addTodo
API會回傳新增的單筆Todo
:
{
"id": "d6a1c390-e729-4c7f-87b2-2f9cb728d6c2",
"text": "test",
"completed": false
}
如果我們要從array形式的response轉換成normalized的形式,可以利用normalizr
這個library。
Define Schema
首先我們要定義資料的schema。我們的todo回傳值可能有單筆或多筆資料,因此我們定義const todo = Schema('todos')
以及const arrayOfTodos = arrayOf(todo)
兩種schema。
import { Schema, arrayOf } from 'normalizr'
export const todo = new Schema('todos')
export const arrayOfTodos = arrayOf(todo)
用normalize(response, schema.todo)
轉換addTodo
API的response
:
import {normalize} from 'normalizr'
export const addTodo = text => dispatch => {
api.addTodo(text).then(response => {
dispatch({
type: 'ADD_TODO_SUCCESS',
response: normalize(response, schema.todo)
})
})
}
轉換之後的結果:
{
"entities": {
"todos": {
"d6a1c390-e729-4c7f-87b2-2f9cb728d6c2": {
"id": "d6a1c390-e729-4c7f-87b2-2f9cb728d6c2",
"text": "test",
"completed": false
}
}
},
"result": "d6a1c390-e729-4c7f-87b2-2f9cb728d6c2"
}
用normalize(response, schema.arrayOfTodos)
轉換fetchTodos
API的response
:
import {normalize} from 'normalizr'
export const fetchTodos = (filter) => (dispatch, getState) => {
// ...
api.fetchTodos(filter).then(
response => {
dispatch({
type: 'FETCH_TODOS_SUCCESS',
filter,
response: normalize(response, schema.arrayOfTodos)
})
},
error => {
dispatch({
type: 'FETCH_TODOS_FAILURE',
filter,
message: error.message || 'Something went wrong!'
})
}
)
轉換之後的結果:
{
"entities": {
"todos": {
"ee05070a-eda5-4fcc-a685-a5cf4be6dc60": {
"id": "ee05070a-eda5-4fcc-a685-a5cf4be6dc60",
"text": "hey",
"completed": true
},
"0bce8ddb-4f8a-44cf-9050-203ecdbb0d93": {
"id": "0bce8ddb-4f8a-44cf-9050-203ecdbb0d93",
"text": "ho",
"completed": true
},
"91451381-2fd6-4f81-b316-cc93f927c34a": {
"id": "91451381-2fd6-4f81-b316-cc93f927c34a",
"text": "let’s go",
"completed": false
}
}
},
"result": [
"ee05070a-eda5-4fcc-a685-a5cf4be6dc60",
"0bce8ddb-4f8a-44cf-9050-203ecdbb0d93",
"91451381-2fd6-4f81-b316-cc93f927c34a"
]
}
可以看到轉換的結果,分成兩個部分:
entities
:一個mapping table,entities.todos
對應我們定義的todos
schema,是todo實體的集合。result
:todo的id
,差別在於addTodo
回傳的是單筆id
,而fetchTodos
回傳的是id
array。
Simplify Reducer
normalizr
處理過後的格式可以很好地對應到我們想要的normalized state。
byId
reducer:action.response.entities.todos
就是todo實體,所以只要直接合併進原本的state即可。
const byId = (state = {}, action) => {
if (action.response) {
return {
...state,
...action.response.entities.todos
}
}
return state
};
ids
reducer:action.response.result
就是ids
state所表示的todo array。在ADD_TODO_SUCCESS
的情況下,result
為單筆,append至state尾端即可;在FETCH_TODO_SUCCESS
的情況下,result
為array,直接取代原本的state即可。
const ids = (state = [], action) => {
switch (action.type) {
case 'FETCH_TODOS_SUCCESS':
return action.filter === filter ?
action.response.result : // result is an array of ids
state
case 'ADD_TODO_SUCCESS':
return filter !== 'completed' ?
[...state, action.response.result] : // result is the id
state
default:
return state
}
}