🎯

Redux 對複雜 State 的策略

2023/09/03に公開

前言

文章內容為 codecademy 的學習筆記。

透過前面兩篇的文章,可以知道 Redux 在 state-management 時好用的地方。然而,之前的例子還不足以說明 Redux 的強大,所以這篇文章會用一個更複雜的 application,來學習 Redux 如何應對更複雜的 state-management。

這邊的範例會練習一個 Recipes App,它會有以下幾個功能:

  1. 會展示從 database 獲得的 Recipes。
  2. 可以讓使用者去增加以及移除最愛的 Recipes。
  3. 讓使用者利用搜尋關鍵字找尋 Recipes。

Slices

Redux 很適合處理多功能的複雜 application,且它的功能都有一些與狀態相關的資料需要管理。在這種狀況下,通常會用 object 來代表整個 store's state 的資料型別。

舉例一個 todo app,它可以讓使用者做以下的動作:

  1. 增加 todo list。
  2. 為每個 todo 事項標記完成或未完成。
  3. 可以選擇只顯示完成事項、未完成事項或是全部顯示。

這個 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 做好兩個關鍵的動作:

  1. 規劃好 state 的結構。
  2. 提供最初的 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:

  1. allRecipes : 是一個 recipe objects 陣列。
  2. favoriteRecipes : 從 state.allRecipes 選出的 recipe objects 陣列。
  3. 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:

  1. 'allRecipes/loadData' : 當 application 啟動時,此 action 會被派去從 API 抓取所需的資料。
  2. 'favoriteRecipes/addRecipe' : 當使用者點擊 ❤️ icon 時,會把食譜加入最愛。
  3. 'favoriteRecipes/removeRecipe' : 當使用者點擊 💔 icon 時,會把食譜從最愛中移出。
  4. 'searchTerm/setSearchTerm' : 當使用者透過改變搜尋框內的文字去篩選食譜時觸發。
  5. '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 都會採取相同的步驟:

  1. 會呼叫 slice reducer,並把 slice of the state 以及 actions 當成參數。
  2. 把回傳的 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 以及 Redux store 的。
  • 在 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 的資料包含:

  1. 要渲染的 slice of the store’s state。舉例來說 state.searchTerm slice 會被傳遞到 <SearchTerm /> component
  2. 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 時,會包含幾個步驟:

  1. import the React feature-component 到 top-level App.js file。
  2. 渲染每一個 feature-component,並且以 props 的形式,傳遞 the slice of state 以及 dispatch 方法。
  3. 而在每個 feature-component 中:
    • 要從 props 中提取 the slice of state 以及 dispatch
    • 用來是 the slice of state 的資料來渲染 component。
    • 從相關聯的 slice file import action creators。
    • 發送 actions 來響應使用者在 component 的輸入。

這個過程其實跟前一篇文章的React + Redux 的方法一樣。但現在會比較需要注意的是 the slice of state 以及 dispatch 需要透過 props 來傳遞。

所以最後再完成幾個工作就完成了:

  1. 在 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())
  );
}
  1. FavoriteRecipes.js 內:
    1. 從 props 拆分出 favoriteRecipes, dispatch。
    2. Map the recipe objects in favoriteRecipes to render components.
    3. import the action creator function, removeRecipe.
    4. Within onRemoveRecipeHandler(), which receives a recipeparameter, dispatch a removeRecipe() action with recipeas 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>
    );
  }
};

文章參考

Learn Redux

Discussion