React Hooks - 保持参数引用处于状态

React Hooks - keep arguments reference in state

我创建了一个挂钩来使用确认对话框,这个挂钩为组件提供属性以像这样使用它们:

const { setIsDialogOpen, dialogProps } = useConfirmDialog({
  title: "Are you sure you want to delete this group?",
  text: "This process is not reversible.",
  buttons: {
    confirm: {
      onPress: onDeleteGroup,
    },
  },
  width: "360px",
});
<ConfirmDialog {...dialogProps} />

这很好用,但我也想提供在需要时更改这些属性的选项,而无需在使用的组件中声明额外的状态,为了实现这一点,我所做的是将这些属性保存在挂钩内的状态,这种方式提供了另一个功能来在显示对话框之前根据需要更改它们:

interface IState {
  isDialogOpen: boolean;
  dialogProps: TDialogProps;
}

export const useConfirmDialog = (props?: TDialogProps) => {
  const [state, setState] = useState<IState>({
    isDialogOpen: false,
    dialogProps: {
      ...props,
    },
  });

  const setIsDialogOpen = (isOpen = true) => {
    setState((prevState) => ({
      ...prevState,
      isDialogOpen: isOpen,
    }));
  };

  // Change dialog props optionally before showing it
  const showConfirmDialog = (dialogProps?: TDialogProps) => {
    if (dialogProps) {
      const updatedProps = { ...state.dialogProps, ...dialogProps };

      setState((prevState) => ({
        ...prevState,
        dialogProps: updatedProps,
      }));
    }
    setIsDialogOpen(true);
  };

  return {
    setIsDialogOpen,
    showConfirmDialog,
    dialogProps: {
      isOpen: state.isDialogOpen,
      onClose: () => setIsDialogOpen(false),
      ...state.dialogProps,
    },
  };
};

但是这里的问题是:
参数通过引用传递,因此如果我将函数传递给按钮(即 onDeleteGroup),我会将函数更新到其最新状态,以便在其中的组 ID 更改时执行正确的删除。
但是当我将属性保存在一个状态中时,引用丢失了,现在我只有函数和它在开始时声明的状态。 我尝试添加一个 useEffect 来在参数更改时更新挂钩状态,但这会导致无限重新渲染:

useEffect(() => {
    setState((prevState) => ({
        ...prevState,
        dialogProps: props || {},
    }));
}, [props]);

我知道我可以调用 showConfirmDialog 并传递函数以使用最新的函数状态更新状态,但我正在寻找一种方法来调用挂钩、声明道具而不触及对话框道具(如果不是)不需要。 欢迎大家回答,谢谢阅读

你真的应该考虑不这样做,这不是一个好的编码模式,这会使你的钩子不必要地复杂化,并可能导致难以调试的问题。这也违背了“单一事实来源”的原则。我的意思是像下面这样的情况

const Component = ({title}: {title?: string}) => {
  const {showConfirmDialog} = useConfirmDialog({
    title,
    // ...
  })

  useEffect(() => {
    // Here you expect the title to be "title"
    if(something) showConfirmDialog()
  }, [])
  useEffect(() => {
    // Here you expect the title to be "Foo bar?"
    if(somethingElse) showConfirmDialog({title: 'Foo bar?'})
  }, [])
  // But if the second dialog is opened, then the first, the title will be 
  // "Foo bar?" in both cases
}

所以在实现这个之前请三思,有时候多写一点代码会更好,但它会节省你很多调试时间。


至于答案,我会将道具存储在 ref 中并在每次渲染时以某种方式更新它们

/** Assign properties from obj2 to obj1 that are not already equal */
const assignChanged = <T extends Record<string, unknown>>(obj1: T, obj2: Partial<T>, deleteExcess = true): T => {
  if(obj1 === obj2) return obj1
  
  const result = {...obj1}
  Object.keys(obj2).forEach(key => {
    if(obj1[key] !== obj2[key]) {
      result[key] = obj2[key]
    }
  })
  if(deleteExcess) {
    // Remove properties that are not present on obj2 but present on obj1
    Object.keys(obj1).forEach(key => { 
      if(!obj2.hasOwnProperty(key)) delete result[key]
    })
  }
  return result
}

const useConfirmDialog = (props) => {
  const localProps = useRef(props)
  localProps.current = assignChanged(localProps.current, props)

  const showConfirmDialog = (changedProps?: Partial<TDialogProps>) => {
    localProps.current = assignChanged(localProps.current, changedProps, false)
    // ...
  }
  
  // ...
}

这是为了防止您在 TDialogProps 中有一些可选属性并且您想在 showConfirmDialog 中接受 Partial 属性。如果不是这种情况,您可以通过删除此 deleteExcess 部分来稍微简化逻辑。

您会看到它使您的代码变得非常复杂,并增加了性能开销(尽管它微不足道,考虑到您的对话框道具中只有 4-5 个字段),所以我真的建议不要这样做,而只是让调用者useConfirmDialog 有自己可以改变的状态。或者你可以首先从 useConfirmDialog 中删除 props 并强制用户始终将它们传递给 showConfirmDialog,尽管在这种情况下这个钩子变得有点无用了。也许你根本不需要这个钩子,如果它只包含你在答案中实际显示的逻辑?看起来它所做的唯一一件事就是将 isDialogOpen 设置为 true/false。不管怎样,这是你的选择,但我认为这不是最好的主意