useLoopCallback — useCallback hook for components created inside a loop

若如初见. 提交于 2019-12-02 07:09:25

问题


I'd like to start a discussion on the recommended approach for creating callbacks that take in a parameter from a component created inside a loop.

For example, if I'm populating a list of items that will have a "Delete" button, I want the "onDeleteItem" callback to know the index of the item to delete. So something like this:

  const onDeleteItem = useCallback(index => () => {
    setList(list.slice(0, index).concat(list.slice(index + 1)));
  }, [list]);

  return (
    <div>
      {list.map((item, index) =>
        <div>
          <span>{item}</span>
          <button type="button" onClick={onDeleteItem(index)}>Delete</button>
        </div>
      )}
    </div>
  ); 

But the problem with this is that onDeleteItem will always return a new function to the onClick handler, causing the button to be re-rendered, even when the list hasn't changed. So it defeats the purpose of useCallback.

I came up with my own hook, which I called useLoopCallback, that solves the problem:

import React, {useCallback, useRef} from "react";

export function useLoopCallback(code, dependencies) {
  const callback = useCallback(code, dependencies);
  const ref = useRef({map: new Map(), callback});
  const store = ref.current;

  if (callback !== store.callback) {
    store.map.clear();
    store.callback = callback;
  }

  return loopParam => {
    let loopCallback = store.map.get(loopParam);
    if (!loopCallback) {
      loopCallback = (...otherParams) => callback(loopParam, ...otherParams);
      store.map.set(loopParam, loopCallback);
    }
    return loopCallback;
  }
}

So now the above handler looks like this:

  const onDeleteItem = useLoopCallback(index => {
    setList(list.slice(0, index).concat(list.slice(index + 1)));
  }, [list]);

This works fine but now I'm wondering if this extra logic is really making things faster or just adding unnecessary overhead. Can anyone please provide some insight?

EDIT: An alternative to the above is to wrap the list items inside their own component. So something like this:

function ListItem({key, item, onDeleteItem}) {
  const onDelete = useCallback(() => {
    onDeleteItem(key);
  }, [onDeleteItem, key]);

  return (
    <div>
      <span>{item}</span>
      <button type="button" onClick={onDelete}>Delete</button>
    </div>
  );
}

export default function List(...) {
  ...

  const onDeleteItem = useCallback(index => {
    setList(list.slice(0, index).concat(list.slice(index + 1)));
  }, [list]);

  return (
    <div>
      {list.map((item, index) =>
        <ListItem key={index} item={item} onDeleteItem={onDeleteItem} />
      )}
    </div>
  ); 
}

回答1:


The List component manages it's own state (list) the delete functions depends on this list being available in it's closure. So when the list changes the delete function must change.

With redux this would not be a problem because deleting items would be accomplished by dispatching an action and will be changed by a reducer that is always the same function.

React happens to have a useReducer hook that you can use:

import React, { useMemo, useReducer, memo } from 'react';

const Item = props => {
  //calling remove will dispatch {type:'REMOVE', payload:{id}}
  //no arguments are needed
  const { remove } = props;
  console.log('component render', props);
  return (
    <div>
      <div>{JSON.stringify(props)}</div>
      <div>
        <button onClick={remove}>REMOVE</button>
      </div>
    </div>
  );
};
//wrap in React.memo so when props don't change
//  the ItemContainer will not re render (pure component)
const ItemContainer = memo(props => {
  console.log('in the item container');
  //dispatch passed by parent use it to dispatch an action
  const { dispatch, id } = props;
  const remove = () =>
    dispatch({
      type: 'REMOVE',
      payload: { id },
    });
  return <Item {...props} remove={remove} />;
});
const initialState = [{ id: 1 }, { id: 2 }, { id: 3 }];
//Reducer is static it doesn't need list to be in it's
// scope through closure
const reducer = (state, action) => {
  if (action.type === 'REMOVE') {
    //remove the id from the list
    return state.filter(
      item => item.id !== action.payload.id
    );
  }
  return state;
};
export default () => {
  //initialize state and reducer
  const [list, dispatch] = useReducer(
    reducer,
    initialState
  );
  console.log('parent render', list);
  return (
    <div>
      {list.map(({ id }) => (
        <ItemContainer
          key={id}
          id={id}
          dispatch={dispatch}
        />
      ))}
    </div>
  );
};



回答2:


Performance optimizations always come with a cost. Sometimes this cost is lower than the operation to be optimized, sometimes is higher. useCallback it's a hook very similar to useMemo, actually you can think of it as a specialization of useMemo that can only be used in functions. For example, the bellow statements are equivalents

const callback = value => value * 2

const memoizedCb = useCallback(callback, [])
const memoizedwithUseMemo = useMemo(() => callback(), [])

So for now on every assertion about useCallback can be applied to useMemo.

The gist of memoization is to keep copies of old values to return in the event we get the same dependencies, this can be great when you have something that is expensive to compute. Take a look at the following code

const Component = ({ items }) =>{
    const array = items.map(x => x*2)
}

Uppon every render the const array will be created as a result of a map performed in items. So you can feel tempted to do the following

const Component = ({ items }) =>{
    const array = useMemo(() => items.map(x => x*2), [items])
}

Now items.map(x => x*2) will only be executed when items change, but is it worth? The short answer is no. The performance gained by doing this is trivial and sometimes will be more expensive to use memoization than just execute the function each render. Both hooks(useCallback and useMemo) are useful in two distinct use cases:

  • Referencial equality

When you need to ensure that a reference type will not trigger a re render just for failing a shallow comparison

  • Computationally expensive operations(only useMemo)

Something like this

const serializedValue = {item: props.item.map(x => ({...x, override: x ? y : z}))}

Now you have a reason to memoized the operation and lazily retrieve the serializedValue everytime props.item changes:

const serializedValue = useMemo(() => ({item: props.item.map(x => ({...x, override: x ? y : z}))}), [props.item])

Any other use case is almost always worth to just re compute all values again, React it's pretty efficient and aditional renders almost never cause performance issues. Keep in mind that sometimes your efforts to optimize your code can go the other way and generate a lot of extra/unecessary code, that won't generate so much benefits (sometimes will only cause more problems).



来源:https://stackoverflow.com/questions/57663003/useloopcallback-usecallback-hook-for-components-created-inside-a-loop

易学教程内所有资源均来自网络或用户发布的内容,如有违反法律规定的内容欢迎反馈
该文章没有解决你所遇到的问题?点击提问,说说你的问题,让更多的人一起探讨吧!