Redux 對複雜 State 的策略
前言
文章內容為 codecademy 的學習筆記。
透過前面兩篇的文章,可以知道 Redux 在 state-management 時好用的地方。然而,之前的例子還不足以說明 Redux 的強大,所以這篇文章會用一個更複雜的 application,來學習 Redux 如何應對更複雜的 state-management。
這邊的範例會練習一個 Recipes App,它會有以下幾個功能:
- 會展示從 database 獲得的 Recipes。
- 可以讓使用者去增加以及移除最愛的 Recipes。
- 讓使用者利用搜尋關鍵字找尋 Recipes。
Slices
Redux 很適合處理多功能的複雜 application,且它的功能都有一些與狀態相關的資料需要管理。在這種狀況下,通常會用 object 來代表整個 store's state 的資料型別。
舉例一個 todo app,它可以讓使用者做以下的動作:
- 增加 todo list。
- 為每個 todo 事項標記完成或未完成。
- 可以選擇只顯示完成事項、未完成事項或是全部顯示。
這個 todo app 可能會長以下這個樣子:
state = {
todos: [
{
id: 0,
text: 'Complete the Learn Redux course',
isCompleted: false
},
{
id: 1,
text: 'Build a counter app',
isCompleted: true
},
],
visibilityFilter: 'SHOW_INCOMPLETE'
};
可以看到在 the top-level state 中, state.todos
以及 state.visibilityFilter
被視為 slice。每個 slice 代表不同的功能,它可以是任何型別,像上面是 object 或是 string。
而大多數複雜的 application 都會從 initialState
開始著手,它會讓 programmer 做好兩個關鍵的動作:
- 規劃好 state 的結構。
- 提供最初的 state value 給 reducer function。
若以上面的 todo app 來說,大概會長這樣:
const initialState = {
todos: [],
visibilityFilter: 'SHOW_ALL'
};
const todosReducer = (state = initialState, action) => {
// rest of todosReducer logic omitted
};
若回到 Recipes App 來說,它會有以下幾個 slice:
-
allRecipes
: 是一個 recipe objects 陣列。 -
favoriteRecipes
: 從state.allRecipes
選出的 recipe objects 陣列。 -
searchTerm
: 用來過濾 recipe 關鍵字的字串。
所以 Store’s state 大概長以下這樣:
state = {
allRecipes: [
{id: 0, name: 'Jjampong', img: 'img/jjampong.png' },
{id: 2, name: 'Cheeseburger', img: 'img/cheeseburger.png' },
//… more recipes omitted
],
favoriteRecipes: [
{id: 1, name: 'Doro Wat', img: 'img/doro-wat.png' },
],
searchTerm: 'Doro'
};
目前的話可以先宣告初始值:
const initialState = {
allRecipes:[],
favoriteRecipes: [],
searchTerm: ''
};
Actions and Payloads For Complex State
現在可以想想使用者要如何透過 actions 的觸發機制,去改變 slices of state。
提醒一下,actions 在 Redux 中常被表示為擁有 type property 的 JavaScript objects,也常使用
store.dispatch()
來發送給 store。
當一個 application state 有很多 slices,每一個 actions 通常一次只改變一個 slice。因此會建議每個 action's type 可以 follow 'sliceName / actionDescriptor'
這樣的格式,來確認說哪一個 slice of state 應該要更新。
舉例來說,todo app 有一個 state.todo
slice,那它用來新增事項的 action type 就可以設為 'todos/addTodo’
。
設定 Recipes action:
-
'allRecipes/loadData'
: 當 application 啟動時,此 action 會被派去從 API 抓取所需的資料。 -
'favoriteRecipes/addRecipe'
: 當使用者點擊 ❤️ icon 時,會把食譜加入最愛。 -
'favoriteRecipes/removeRecipe'
: 當使用者點擊 💔 icon 時,會把食譜從最愛中移出。 -
'searchTerm/setSearchTerm'
: 當使用者透過改變搜尋框內的文字去篩選食譜時觸發。 -
'searchTerm/clearSearchTerm'
: 當使用者點擊在搜尋框旁的 ‘X’ 按鈕時觸發。
在這之中同時也要考慮哪一些 actions 會需要 payload,目的是讓額外的資料可以傳到 reducer 中,把更新的資料傳送到目標的 state。比如以下的例子:
store.dispatch({
type: 'searchTerm/setSearchTerm',
payload: 'Spaghetti'
});
// The resulting state: { ..., searchTerm: 'Spaghetti' }
store.dispatch({
type: 'searchTerm/clearSearchTerm'
});
// The resulting state: { ..., searchTerm: '' }
- 上面需要 payload 是因為需要把資料傳給 store,來讓 React component 知道要渲染哪一個 Recipes。
- 而下面是當要清空搜尋框時,不需要傳送額外的資料,所以不需要 payload。
到現在對每個 actions 都有清楚的規劃後,下一步就是設定要回傳 action object 的 action creators functions。
以下就把剩下的 actions 設定完:
import allRecipesData from "./data.js";
const initialState = {
allRecipes: [],
favoriteRecipes: [],
searchTerm: "",
};
// Dispatched when the user types in the search input.
// Sends the search term to the store.
const setSearchTerm = (term) => {
return {
type: "searchTerm/setSearchTerm",
payload: term,
};
};
// Dispatched when the user presses the clear search button.
const clearSearchTerm = () => {
return {
type: "searchTerm/clearSearchTerm",
};
};
// Dispatched when the user first opens the application.
// Sends the allRecipesData array to the store.
const loadData = () => {
return { type: "allRecipes/loadData", payload: allRecipesData };
};
// Dispatched when the user clicks on the heart icon of
// a recipe in the "All Recipes" section.
// Sends the recipe object to the store.
const addRecipe = (recipe) => {
return { type: "favoriteRecipes/addRecipe", payload: recipe };
};
// Dispatched when the user clicks on the broken heart
// icon of a recipe in the "Favorite Recipes" section.
// Sends the recipe object to the store.
const removeRecipe = (recipe) => {
return { type: "favoriteRecipes/removeRecipe", payload: recipe };
};
Immutable Updates & Complex State
在前面設定完在 state 會有哪些更新與變化後,接下來需要利用 reducer 來做執行。
小提醒:當一個 action 被發送時,store's reducer function 每次都會被呼叫。它會以 action 以及 the current state 為參數,並回傳下一個 store’s state。
以下舉個例子來看看在 todo app 裡 todoReducer 的設置:
const initialState = {
filter: 'SHOW_INCOMPLETE',
todos: [
{ id: 0, text: 'learn redux', completed: false },
{ id: 1, text: 'build a redux app', completed: true },
{ id: 2, text: 'do a dance', completed: false },
]
};
const todosReducer = (state = initialState, action) => {
switch (action.type) {
case 'filter/setFilter':
return {
...state,
filter: action.payload
};
case 'todos/addTodo':
return {
...state,
todos: [...state.todos, action.payload]
} ;
case 'todos/toggleTodo':
return {
...state,
todos: state.todos.map(todo => {
return (todo.id === action.payload.id) ?
{ ...todo, completed: !todo.completed } :
todo;
})
}
default:
return state;
}
};
- 這邊說一下上面
state.todos.map()
的部分是利用淺拷貝的方式,代表新物件跟原物件會共享同一個 references,所以如果改變新物件的東西時,也會影響到原物件。
Recipes’s reducer:
const recipesReducer = (state = initialState, action) => {
switch (action.type) {
case "allRecipes/loadData":
return {
...state,
allRecipes: action.payload,
};
case "searchTerm/clearSearchTerm":
return {
...state,
searchTerm: "",
};
case "searchTerm/setSearchTerm":
return {
...state,
searchTerm: action.payload,
};
case "favoriteRecipes/addRecipe":
return {
...state,
favoriteRecipes: [...state.favoriteRecipes, action.payload],
};
case "favoriteRecipes/removeRecipe":
return {
...state,
favoriteRecipes: state.favoriteRecipes.filter(element => {
element.id !== action.payload.id
};
default:
return state;
}
};
const store = createStore(recipesReducer);
Reducer Composition
在上一個程式碼中,可以看到 reducer 如何去整合每個 slice of the store
‘s state 裡的邏輯去做更新。但隨著 state 愈來愈複雜的情況下,如果只有一個 reducer 可能不切實際。
而解決的方法是可以 follow 一個叫 reducer composition 的 pattern。在這個 pattern 中,每個 slice reducer 都只負責更新一個 slice of the application’s state,而它們的結果會藉由 rootReducer
來重組成一個 state object。
// Handles only `state.todos`.
const initialTodos = [
{ id: 0, text: 'learn redux', completed: false },
{ id: 1, text: 'build a redux app', completed: true },
{ id: 2, text: 'do a dance', completed: false },
];
const todosReducer = (todos = initialTodos, action) => {
switch (action.type) {
case 'todos/addTodo':
return [...todos, action.payload]
case 'todos/toggleTodo':
return todos.map(todo => {
return (todo.id === action.payload.id) ?
{ ...todo, completed: !todo.completed } :
{...todo};
});
default:
return todos;
}
};
// Handles only `state.filter`
const initialFilter = 'SHOW_INCOMPLETE',
const filterReducer = (filter = initialFilter, action) => {
switch (action.type) {
case 'filter/setFilter':
return action.payload;
default:
return filter;
};
const rootReducer = (state = {}, action) => {
const nextState = {
todos: todosReducer(state.todos, action),
filter: filterReducer(state.filter, action)
};
return nextState;
};
const store = createStore(rootReducer);
稍微說明一下在上面例子中,當 action 被發送到 store 時的一些細節:
-
rootReducer
會呼叫每一個 slice reducer,不管action.type
的形式,都會以輸入的 action 以及 slice’s state 為參數。 - 每個 slice reducer 都要決定它們是否需要去更新它們的 slice’s state,或是只回傳它們沒改變的 slice’s state。
這個方法最大的優點在於每個 slice reducer 會接收整個 application state 自己的 slice。因此每個 slice reducer 只需要不間斷地更新自己的 slice,而不需去在意其他的。如此一來就解決了拷貝帶來潛在的 deeply nested state objects 問題。
經過 Reducer Composition 的 Recipes App:
import { createStore } from 'redux';
import allRecipesData from './data.js';
// Action Creators
////////////////////////////////////////
const addRecipe = (recipe) => {
return {
type: 'favoriteRecipes/addRecipe',
payload: recipe
};
}
const removeRecipe = (recipe) => {
return {
type: 'favoriteRecipes/removeRecipe',
payload: recipe
};
}
const setSearchTerm = (term) => {
return {
type: 'searchTerm/setSearchTerm',
payload: term
}
}
const clearSearchTerm = () => {
return {
type: 'searchTerm/clearSearchTerm'
};
}
const loadData = () => {
return {
type: 'allRecipes/loadData',
payload: allRecipesData
};
}
// Reducers
////////////////////////////////////////
const initialAllRecipes = [];
const allRecipesReducer = (allRecipes = initialAllRecipes, action) => {
switch(action.type) {
case 'allRecipes/loadData':
return action.payload
default:
return allRecipes;
}
}
const initialSearchTerm = '';
const searchTermReducer = (searchTerm = initialSearchTerm, action) => {
switch(action.type) {
case 'searchTerm/setSearchTerm':
return action.payload;
case 'searchTerm/clearSearchTerm':
return '';
default:
return searchTerm;
}
}
// Create the initial state for this reducer.
var initialFavoriteRecipes = [];
const favoriteRecipesReducer = (favoriteRecipes = initialFavoriteRecipes, action) => {
switch(action.type) {
// Add action.type cases here.
case 'favoriteRecipes/addRecipe':
return [...favoriteRecipes, action.payload];
case 'favoriteRecipes/removeRecipe':
return favoriteRecipes.filter(element => element.id !== action.payload.id);
default:
return favoriteRecipes;
}
}
const rootReducer = (state = {}, action) => {
const nextState = {
allRecipes: allRecipesReducer(state.allRecipes, action),
searchTerm: searchTermReducer(state.searchTerm, action),
favoriteRecipes: favoriteRecipesReducer(state.favoriteRecipes, action)
}
return nextState;
}
const store = createStore(rootReducer);
combineReducers
在 reducer composition pattern 中,rootReducer
對於每一個 slice reducer 都會採取相同的步驟:
- 會呼叫 slice reducer,並把 slice of the
state
以及 actions 當成參數。 - 把回傳的 slice of state 存放在最終由
rootReducer()
回傳的新 object 中。
import { createStore } from 'redux';
// todosReducer and filterReducer omitted
const rootReducer = (state = {}, action) => {
const nextState = {
todos: todosReducer(state.todos, action),
filter: filterReducer(state.filter, action)
};
return nextState;
};
const store = createStore(rootReducer);
那其中,Redux 套件透過提供一個實用的 function combineReducers()
,更容易地執行這樣的 pattern。
import { createStore, combineReducers } from 'redux'
// todosReducer and filterReducer omitted.
const reducers = {
todos: todosReducer,
filter: filterReducer
};
const rootReducer = combineReducers(reducers);
const store = createStore(rootReducer);
稍微把這段程式碼拆解一下:
-
reducers
包含給 application 的 slice reducer,每個 object 的 keys 對應被reducer value 中的 slice 名字。 -
combineReducers()
function 接受reducers
object 並回傳給rootReducer
function。 - 回傳的
rootReducer
會被傳送到createStore()
來創建一個 store object。
就如同以往,當 action 被發送到 store 時,rootReducer
會被執行,且呼叫每一個 slice reducer ,並傳送 action 以及適當的 slice of state。而上面最後 6 行的程式碼可以寫成以下這樣:
const store = createStore(combineReducers({
todos: todosReducer,
filter: filterReducer
}));
Recipes App’s code:
// Create your `rootReducer` here using combineReducers().
const reducers = {
allRecipes: allRecipesReducer,
favoriteRecipes: favoriteRecipesReducer,
searchTerm: searchTermReducer
};
const rootReducer = combineReducers(reducers);
const store = createStore(rootReducer);
關於 Redux 的檔案結構
目前看下來,儘管我們只有三個 slice ,但程式碼都在同一個檔案似乎有點太長太複雜了。如果未來還有更多邏輯需要處理,那情況將會複雜許多。
所以這邊介紹一個 Redux application : Redux Ducks pattern,像以下這樣
src/
|-- index.js
|-- app/
|-- store.js
|-- features/
|-- featureA/
|-- featureASlice.js
|-- featureB/
|-- featureBSlice.js
這是一個在你打開編輯器時,它就會為他設定好的架構,可以依據不同的功能為檔案做分類。
-
store.js
是要最後拿來創建rootReducer
以及 Reduxstore
的。 - 在 features 中的每個 feature 資料夾以及它們的檔案,都包含每一個獨立的 slice of the
store
’s state。舉例來說state.favoriteRecipes
slice,它的 slice reducer以及 action creators 可以在 src/features/favoriteRecipes/favoriteRecipesSlice.js 的檔案裡找到。
Recipes App’s store.js & index.js
store.js
import { createStore, combineReducers } from 'redux';
// Import the slice reducers here.
import { favoriteRecipesReducer } from '../features/favoriteRecipes/favoriteRecipesSlice.js';
import { allRecipesReducer } from '../features/allRecipes/allRecipesSlice.js';
import { searchTermReducer } from '../features/searchTerm/searchTermSlice.js';
const reducers = {
allRecipes: allRecipesReducer,
searchTerm: searchTermReducer,
favoriteRecipes: favoriteRecipesReducer,
};
// Declare the store here.
const store = createStore(combineReducers(reducers));
export store;
index.js
import React from 'react';
import ReactDOM from 'react-dom';
import { App } from './app/App.js';
// Import 'store' here.
import { store } from './app/store.js'
const render = () => {
// Pass `state` and `dispatch` props to <App />
ReactDOM.render(
<App
state={store.getState()}
dispatch={store.dispatch}
/>,
document.getElementById('root')
)
}
render();
store.subscribe(render)
// Subscribe render to changes to the `store`
藉由 the Top-Level React Component 傳遞 Store Data
這邊會再新增幾個檔案來分開渲染的功能(以(+)標記的)。
src/
|-- index.js
|-- app/
|-- App.js (+)
|-- store.js
|-- components/
|-- FavoriteButton.js (+)
|-- Recipe.js (+)
|-- features/
|-- allRecipes/
|-- AllRecipes.js (+)
|-- allRecipesSlice.js
|-- favoriteRecipes/
|-- FavoriteRecipes.js (+)
|-- favoriteRecipesSlice.js
|-- searchTerm/
|-- SearchTerm.js (+)
|-- searchTermSlice.js
這些新的 component 分別用來:
-
<App />
: 作為整個應用程式的 top-level component。 -
<AllRecipes />
: 這個 component 用來渲染從 database 載入的 Recipes。 -
<FavoriteRecipes />
: 這個 component 用來渲染使用者最愛的 Recipes。 -
<SearchTerm />
: 這個 component 用來渲染透過搜尋框篩選的 Recipes。 -
<Recipe />
and<FavoriteButton />
: 提供給<AllRecipes />
以及<FavoriteRecipes />
使用的。
接下看一下這些 App.js 怎麼設置的:
import React from "react";
import { AllRecipes } from "../features/allRecipes/AllRecipes.js";
import { SearchTerm } from "../features/searchTerm/SearchTerm.js";
export function App(props) {
const { state, dispatch } = props;
const visibleAllRecipes = getFilteredRecipes(
state.allRecipes,
state.searchTerm
);
const visibleFavoriteRecipes = getFilteredRecipes(
state.favoriteRecipes,
state.searchTerm
);
// You'll add the <FavoriteRecipes /> component in the next exercise!
return (
<main>
<section>
<SearchTerm searchTerm={state.searchTerm} dispatch={dispatch} />
</section>
<section>
<h2>Favorite Recipes</h2>
</section>
<hr />
<section>
<h2>All Recipes</h2>
<AllRecipes allRecipes={visibleAllRecipes} dispatch={dispatch} />
</section>
</main>
);
}
/* Utility Helpers */
function getFilteredRecipes(recipes, searchTerm) {
return recipes.filter((recipe) =>
recipe.name.toLowerCase().includes(searchTerm.toLowerCase())
);
}
<App />
作為 top-level component 會渲染每個有功能的 component,並且會以 props value 的形式,傳遞那些 component 需要的任何資料。而在 Redux 的應用中,傳遞給 feature-component 的資料包含:
- 要渲染的 slice of the
store
’s state。舉例來說state.searchTerm
slice 會被傳遞到<SearchTerm /> component
。 -
store.dispatch
的方法會透過使用者在 component 內的互動,去觸發 state 的改變。舉例來說<SearchTerm />
component 會需要去發送setSearchTerm()
的 action。
在 Feature Components 使用 Store Data
在前一個範例中,可以利用傳遞 the current state of the store
以及它的 store.dispatch
給 top-level component <App />
。這會讓 <App />
component去分配 dispatch
以及 the slices of the store
’s state 給每一個 feature-component。
現在看起來好像完成了,但其實還沒。現在如果新增一個最愛的食譜,只會發現那個食譜不見了而已。而目前看 App.js
中的程式碼,會發現 <FavoriteRecipes />
component 不見了。且若打開 FavoriteRecipes.js
會發現其實還沒完成。下面就會來修正這一部分:
要注意將 feature-component 插入 Redux application 時,會包含幾個步驟:
- import the React feature-component 到 top-level App.js file。
- 渲染每一個 feature-component,並且以 props 的形式,傳遞 the slice of
state
以及dispatch
方法。 - 而在每個 feature-component 中:
- 要從 props 中提取 the slice of
state
以及dispatch
。 - 用來是 the slice of state 的資料來渲染 component。
- 從相關聯的 slice file import action creators。
- 發送 actions 來響應使用者在 component 的輸入。
- 要從 props 中提取 the slice of
這個過程其實跟前一篇文章的React + Redux 的方法一樣。但現在會比較需要注意的是 the slice of state 以及 dispatch 需要透過 props 來傳遞。
所以最後再完成幾個工作就完成了:
- 在 App.js 內插入 <FavoriteRecipes /> component,並設定好它的 favoriteRecipes 以及 dispatch 的 value。
import React from "react";
import { AllRecipes } from "../features/allRecipes/AllRecipes.js";
import { SearchTerm } from "../features/searchTerm/SearchTerm.js";
// Import the FavoriteRecipes component here.
import { FavoriteRecipes } from "../features/favoriteRecipes/FavoriteRecipes.js";
export function App(props) {
const { state, dispatch } = props;
const visibleAllRecipes = getFilteredRecipes(
state.allRecipes,
state.searchTerm
);
const visibleFavoriteRecipes = getFilteredRecipes(
state.favoriteRecipes,
state.searchTerm
);
// Render the <FavoriteRecipes /> component.
// Pass `dispatch` and `favoriteRecipes` props.
return (
<main>
<section>
<SearchTerm searchTerm={state.searchTerm} dispatch={dispatch} />
</section>
<section>
<h2>Favorite Recipes</h2>
<FavoriteRecipes
favoriteRecipes={visibleFavoriteRecipes}
dispatch={dispatch}
/>
</section>
<hr />
<section>
<h2>All Recipes</h2>
<AllRecipes allRecipes={visibleAllRecipes} dispatch={dispatch} />
</section>
</main>
);
}
/* Utility Helpers */
function getFilteredRecipes(recipes, searchTerm) {
return recipes.filter((recipe) =>
recipe.name.toLowerCase().includes(searchTerm.toLowerCase())
);
}
- 在
FavoriteRecipes.js
內:- 從 props 拆分出 favoriteRecipes, dispatch。
- Map the recipe objects in favoriteRecipes to render components.
- import the action creator function,
removeRecipe
. - Within
onRemoveRecipeHandler()
, which receives arecipe
parameter, dispatch aremoveRecipe()
action withrecipe
as an argument.
import React from "react";
import FavoriteButton from "../../components/FavoriteButton";
import Recipe from "../../components/Recipe";
const unfavoriteIconUrl =
"https://static-assets.codecademy.com/Courses/Learn-Redux/Recipes-App/icons/unfavorite.svg";
// Import removeRecipe from favoriteRecipesSlice.js
import { removeRecipe } from './favoriteRecipesSlice.js'
export const FavoriteRecipes = (props) => {
// Extract dispatch and favoriteRecipes from props.
const { favoriteRecipes, dispatch } = props;
const onRemoveRecipeHandler = (recipe) => {
// Dispatch a removeRecipe() action.
dispatch(removeRecipe(recipe));
};
// Map the recipe objects in favoriteRecipes to render <Recipe /> components.
return (
<div id="favorite-recipes" className="recipes-container">
{favoriteRecipes.map(createRecipeComponent)}
</div>
);
// Helper Function
function createRecipeComponent(recipe) {
return (
<Recipe recipe={recipe} key={recipe.id}>
<FavoriteButton
onClickHandler={() => onRemoveRecipeHandler(recipe)}
icon={unfavoriteIconUrl}
>
Remove Favorite
</FavoriteButton>
</Recipe>
);
}
};
Discussion