如何正确地将 useReducer 操作传递给子项而不会导致不必要的渲染 [英] How to properly pass useReducer actions down to children without causing unnecessary renders

查看:44
本文介绍了如何正确地将 useReducer 操作传递给子项而不会导致不必要的渲染的处理方法,对大家解决问题具有一定的参考价值,需要的朋友们下面随着小编来一起学习吧!

问题描述

我不太清楚使用 useReducer 钩子进行数据管理的最佳方式.我的主要目标是减少 (heh) 样板代码并保持代码可读性,同时在性能方面使用最佳方法并防止不必要的重新渲染.

设置

我为我的应用程序创建了一个简化示例,基本上它是一个 <List/> 组件 - 一个可以选择它们的项目列表,以及一个 <Controls/> 组件,可以切换项目组并重新加载数据集.

List.js

import React, { memo } from "react";const List = ({ items, selected, selectItem, deselectItem }) =>{console.log(" render");返回 (<ul className=列表">{items.map(({ id, name }) => (<li key={`item-${name.toLowerCase()}`}><标签><输入类型=复选框"选中={selected.includes(id)}onChange={(e) =>e.target.checked ?selectItem(id) : 取消selectItem(id)}/>{名称}))});};导出默认备忘录(列表);

Controls.js

import React, { memo } from "react";从./constants"导入{项目组};const Controls = ({ group, setGroup, fetchItems }) =>{console.log("<控件/>渲染");返回 (<div className="控件"><标签>选择组<select value={group} onChange={(e) =>setGroup(e.target.value)}><option value={ItemGroups.PEOPLE}>{ItemGroups.PEOPLE}</option><option value={ItemGroups.TREES}>{ItemGroups.TREES}</option></选择><button onClick={() =>fetchItems(group)}>重新加载数据</button>

);};导出默认备忘录(控件);

App.js

import React, { useEffect, useReducer } from "react";从./Controls"导入控件;从./List"导入列表;从./Loader"导入加载器;从./constants"导入{项目组};进口 {FETCH_START,FETCH_成功,SET_GROUP,选择物品,DESELECT_ITEM} 来自./常量";从./api"导入 fetchItemsFromAPI;导入./styles.css";const itemsReducer = (state, action) =>{const { 类型,有效载荷 } = 动作;console.log(`reducer action "${type}" dispatched`);开关(类型){案例 FETCH_START:返回 {...状态,isLoading: 真};案例 FETCH_SUCCESS:返回 {...状态,项目:payload.items,isLoading: 假};案例 SET_GROUP:返回 {...状态,选择:state.selected.length ?[] : state.selected,组:payload.group};案例 SELECT_ITEM:返回 {...状态,选择:[...state.selected,payload.id]};案例 DESELECT_ITEM:返回 {...状态,选择:state.selected.filter((id) => id !== payload.id)};默认:抛出新错误(项目减速器中的未知操作类型");}};导出默认函数 App() {const [状态,调度] = useReducer(itemsReducer, {项目: [],选择:[],组:ItemGroups.PEOPLE,isLoading: 假});const { 项目、组、选定、正在加载 } = 状态;const fetchItems = (组) =>{dispatch({ type: FETCH_START });fetchItemsFromAPI(group).then((items) =>派遣({类型:FETCH_SUCCESS,有效载荷:{ 项目}}));};const setGroup = (组) =>{派遣({类型:SET_GROUP,有效载荷:{组}});};const selectItem = (id) =>{派遣({类型:SELECT_ITEM,有效载荷:{ id }});};const deselectItem = (id) =>{派遣({类型:DESELECT_ITEM,有效载荷:{ id }});};useEffect(() => {console.log(使用对组更改的影响");fetchItems(组);}, [团体]);console.log(" 渲染");返回 (

<Controls {...{ group, fetchItems, setGroup }}/>{正在加载?(<装载机/>) : (<List {...{ items, selected, selectItem, deselectItem }}/>)}

);}

这是

I can't quite figure out the optimal way to use useReducer hook for data management. My primary goal is to reduce (heh) the boilerplate to minimum and maintain code readability, while using the optimal approach in terms of performance and preventing unnecessary re-renders.

The setup

I have created a simplified example of my app, basically it's a <List /> component - a list of items with possibility to select them, and a <Controls /> component which can switch item groups and reload the data set.

List.js

import React, { memo } from "react";

const List = ({ items, selected, selectItem, deselectItem }) => {
  console.log("<List /> render");

  return (
    <ul className="List">
      {items.map(({ id, name }) => (
        <li key={`item-${name.toLowerCase()}`}>
          <label>
            <input
              type="checkbox"
              checked={selected.includes(id)}
              onChange={(e) =>
                e.target.checked ? selectItem(id) : deselectItem(id)
              }
            />
            {name}
          </label>
        </li>
      ))}
    </ul>
  );
};

export default memo(List);

Controls.js

import React, { memo } from "react";

import { ItemGroups } from "./constants";

const Controls = ({ group, setGroup, fetchItems }) => {
  console.log("<Controls /> render");

  return (
    <div className="Controls">
      <label>
        Select group
        <select value={group} onChange={(e) => setGroup(e.target.value)}>
          <option value={ItemGroups.PEOPLE}>{ItemGroups.PEOPLE}</option>
          <option value={ItemGroups.TREES}>{ItemGroups.TREES}</option>
        </select>
      </label>
      <button onClick={() => fetchItems(group)}>Reload data</button>
    </div>
  );
};

export default memo(Controls);

App.js

import React, { useEffect, useReducer } from "react";

import Controls from "./Controls";
import List from "./List";
import Loader from "./Loader";

import { ItemGroups } from "./constants";

import {
  FETCH_START,
  FETCH_SUCCESS,
  SET_GROUP,
  SELECT_ITEM,
  DESELECT_ITEM
} from "./constants";

import fetchItemsFromAPI from "./api";

import "./styles.css";

const itemsReducer = (state, action) => {
  const { type, payload } = action;

  console.log(`reducer action "${type}" dispatched`);

  switch (type) {
    case FETCH_START:
      return {
        ...state,
        isLoading: true
      };

    case FETCH_SUCCESS:
      return {
        ...state,
        items: payload.items,
        isLoading: false
      };

    case SET_GROUP:
      return {
        ...state,
        selected: state.selected.length ? [] : state.selected,
        group: payload.group
      };

    case SELECT_ITEM:
      return {
        ...state,
        selected: [...state.selected, payload.id]
      };

    case DESELECT_ITEM:
      return {
        ...state,
        selected: state.selected.filter((id) => id !== payload.id)
      };

    default:
      throw new Error("Unknown action type in items reducer");
  }
};

export default function App() {
  const [state, dispatch] = useReducer(itemsReducer, {
    items: [],
    selected: [],
    group: ItemGroups.PEOPLE,
    isLoading: false
  });

  const { items, group, selected, isLoading } = state;

  const fetchItems = (group) => {
    dispatch({ type: FETCH_START });

    fetchItemsFromAPI(group).then((items) =>
      dispatch({
        type: FETCH_SUCCESS,
        payload: { items }
      })
    );
  };

  const setGroup = (group) => {
    dispatch({
      type: SET_GROUP,
      payload: { group }
    });
  };

  const selectItem = (id) => {
    dispatch({
      type: SELECT_ITEM,
      payload: { id }
    });
  };

  const deselectItem = (id) => {
    dispatch({
      type: DESELECT_ITEM,
      payload: { id }
    });
  };

  useEffect(() => {
    console.log("use effect on group change");

    fetchItems(group);
  }, [group]);

  console.log("<App /> render");

  return (
    <div className="App">
      <Controls {...{ group, fetchItems, setGroup }} />
      {isLoading ? (
        <Loader />
      ) : (
        <List {...{ items, selected, selectItem, deselectItem }} />
      )}
    </div>
  );
}

Here's the complete sandbox.

The state is managed in a reducer, because I need different parts of state to work and change together. For example, reset selected items on group change (because it makes no sense to keep selections between different data sets), set loaded items and clear loading state on data fetch success, etc. The example is intentionally simple, but in reality there're many dependencies between different parts of state (filtering, pagination, etc.), which makes reducer a perfect tool to manage it - in my opinion.

I've created helper functions to perform different actions (for ex., to reload items or to select/deselect). I could just pass down the dispatch to children and create action objects there, but this turns everything into a mess really quickly, esp. when multiple components must perform same actions.

Problem 1

Passing down reducer action functions to child components causes them to re-render on any reducer update.

  • Case 1: When I select an item in <List />, the <Controls /> is re-rendered.
  • Case 2: When I reload the data on Reload button click, the <Controls /> is re-rendered.

In both cases, the <Controls /> only actually depends on group prop to render, so when it stays the same - the component should not re-render.

I've investigated it and this happens because on each <App /> re-render these action functions are re-created and treated as new prop values for child components, so for React it's simple: new props => new render.

Not ideal solution to this is to wrap all action functions in useCallback, with dispatch as a dependency, but this looks like a hack to me.

const setGroup = useCallback(
  (group) => {
    dispatch({
      type: SET_GROUP,
      payload: { group }
    });
  },
  [dispatch]
);

In a simple example it does not look too bad, but when you have dozens of possible actions, all wrapped in useCallback, with deps arrays - that does not seem right.

And it requires to add even more deps to useEffect (which is another problem).

Here's a "fixed" version with useCallback.

Problem 2

I cannot fully extract reducer action functions outside the <App /> component, because in the end they must be used inside a React component with the dispatch (because it's a hook).

I can of course extract them to a separate module and pass dispatch as a first argument:

in actions.js

// ...
export const fetchItems = (dispatch, group) => {
  dispatch({ type: FETCH_START });

  fetchItemsFromAPI(group).then((items) =>
    dispatch({
      type: FETCH_SUCCESS,
      payload: { items }
    })
  );
};
// ...

and then in child components do this:

import { fetchItems } from './actions';

const Child = ({ dispatch, group }) => {
  fetchItems(dispatch, group);
  // ...
};

and reduce my <App /> to this:

// ...
const App = () => {
  const [{ items, group, selected, isLoading }, dispatch] = useReducer(
    itemsReducer,
    itemReducerDefaults
  );

  useEffect(() => {
    fetchItems(dispatch, group);
  }, [group, dispatch]);

  return (
    <div className="App">
      <Controls {...{ group, dispatch }} />
      {isLoading ? <Loader /> : <List {...{ items, selected, dispatch }} />}
    </div>
  );
};

but then I have to pass around the dispatch (minor issue) and always have it in arguments list. On the other hand, it fixes the Problem 1 as well, as dispatch does not change between renders.

Here's a sandbox with actions and reducer extracted.

But is it optimal, or maybe I should use some entirely different approach?

So, how do you guys use it? The React docs and guides are nice and clean with counter increments and ToDo lists, but how do you actually use it in real world apps?

解决方案

React-redux works by also wrapping all the actions with a call to dispatch; this is abstracted away when using the connect HOC, but still required when using the useDispatch hook. Async actions typically have a function signature (...args) => dispatch => {} where the action creator instead returns a function that accepts the dispatch function provided by redux, but redux requires middleware to handle these. Since you are not actually using Redux you'd need to handle this yourself, likely using a combination of both patterns to achieve similar usage.

I suggest the following changes:

  1. De-couple and isolate your action creators, they should be functions that return action objects (or asynchronous action functions).
  2. Create a custom dispatch function that handles asynchronous actions.
  3. Correctly log when a component renders (i.e. during the commit phase in an useEffect hook and not during any render phase in the component body. See this lifecycle diagram.
  4. Pass the custom dispatch function to children, import actions in children... dispatch actions in children. How to avoid passing callbacks down.
  5. Only conditionally render the Loader component. When you render one or the other of Loader and List the other is unmounted.

Actions (actions.js)

import {
  FETCH_START,
  FETCH_SUCCESS,
  SET_GROUP,
  SELECT_ITEM,
  DESELECT_ITEM
} from "./constants";

import fetchItemsFromAPI from "./api";

export const setGroup = (group) => ({
  type: SET_GROUP,
  payload: { group }
});

export const selectItem = (id) => ({
  type: SELECT_ITEM,
  payload: { id }
});

export const deselectItem = (id) => ({
  type: DESELECT_ITEM,
  payload: { id }
});

export const fetchItems = (group) => (dispatch) => {
  dispatch({ type: FETCH_START });

  fetchItemsFromAPI(group).then((items) =>
    dispatch({
      type: FETCH_SUCCESS,
      payload: { items }
    })
  );
};

useAsyncReducer.js

const asyncDispatch = (dispatch) => (action) =>
  action instanceof Function ? action(dispatch) : dispatch(action);

export default (reducer, initialArg, init) => {
  const [state, syncDispatch] = React.useReducer(reducer, initialArg, init);
  const dispatch = React.useMemo(() => asyncDispatch(syncDispatch), []);
  return [state, dispatch];
};

Why doesn't useMemo need a dependency on useReducer dispatch function?

useReducer

Note

React guarantees that dispatch function identity is stable and won’t change on re-renders. This is why it’s safe to omit from the useEffect or useCallback dependency list.

We want to also provide a stable dispatch function reference.

App.js

import React, { useEffect } from "react";
import useReducer from "./useAsyncReducer";

import Controls from "./Controls";
import List from "./List";
import Loader from "./Loader";

import { ItemGroups } from "./constants";

import {
  FETCH_START,
  FETCH_SUCCESS,
  SET_GROUP,
  SELECT_ITEM,
  DESELECT_ITEM
} from "./constants";
import { fetchItems } from "./actions";

export default function App() {
  const [state, dispatch] = useReducer(itemsReducer, {
    items: [],
    selected: [],
    group: ItemGroups.PEOPLE,
    isLoading: false
  });

  const { items, group, selected, isLoading } = state;

  useEffect(() => {
    console.log("use effect on group change");

    dispatch(fetchItems(group));
  }, [group]);

  React.useEffect(() => {
    console.log("<App /> render");
  });

  return (
    <div className="App">
      <Controls {...{ group, dispatch }} />
      {isLoading && <Loader />}
      <List {...{ items, selected, dispatch }} />
    </div>
  );
}

Controls.js

import React, { memo } from "react";
import { ItemGroups } from "./constants";
import { setGroup, fetchItems } from "./actions";

const Controls = ({ dispatch, group }) => {
  React.useEffect(() => {
    console.log("<Controls /> render");
  });

  return (
    <div className="Controls">
      <label>
        Select group
        <select
          value={group}
          onChange={(e) => dispatch(setGroup(e.target.value))}
        >
          <option value={ItemGroups.PEOPLE}>{ItemGroups.PEOPLE}</option>
          <option value={ItemGroups.TREES}>{ItemGroups.TREES}</option>
        </select>
      </label>
      <button onClick={() => dispatch(fetchItems(group))}>Reload data</button>
    </div>
  );
};

List.js

import React, { memo } from "react";
import { deselectItem, selectItem } from "./actions";

const List = ({ dispatch, items, selected }) => {
  React.useEffect(() => {
    console.log("<List /> render");
  });

  return (
    <ul className="List">
      {items.map(({ id, name }) => (
        <li key={`item-${name.toLowerCase()}`}>
          <label>
            <input
              type="checkbox"
              checked={selected.includes(id)}
              onChange={(e) =>
                dispatch((e.target.checked ? selectItem : deselectItem)(id))
              }
            />
            {name}
          </label>
        </li>
      ))}
    </ul>
  );
};

Loader.js

const Loader = () => {
  React.useEffect(() => {
    console.log("<Loader /> render");
  });

  return <div>Loading data...</div>;
};

这篇关于如何正确地将 useReducer 操作传递给子项而不会导致不必要的渲染的文章就介绍到这了,希望我们推荐的答案对大家有所帮助,也希望大家多多支持IT屋!

查看全文
登录 关闭
扫码关注1秒登录
发送“验证码”获取 | 15天全站免登陆