乐观渲染&useEffect

Optimistic rendering & useEffect

TL'DR: 有没有办法在使用 useEffect 进行 API 调用时悲观地操作状态变化?

假设您编写了一个显示分页/排序数据网格的组件,并且您为该组件编写了一个 useEffect,类似于这个假示例:

useEffect(() => {
  fetch(`https://some-random-api.com/data/page/{paging}/sort/{sorting}`)
    .then(response => response.json())
    .then(data => setState({
      data
    }));
}, [paging, sorting]);

因此,如果我没记错的话,这将在分页或排序 states/props (whatev) 更新时获取。这意味着它在加载数据之前乐观地呈现新的排序和分页状态。基本上,应用程序会告诉用户 "I'm on the requested page with the requested sort but I'm still loading it"。换句话说,部分应用程序状态(分页和排序)表示 "I'm done" 状态的另一部分(加载)表示 "I'm working on it".

相比之下,如果我要悲观地更新状态,我只会在获取之前将加载状态设置为真,然后在收到响应时(并且仅在那时)设置分页、排序(&数据&加载)。因此,该应用会告诉用户 "I heard you and I'm now working on it",然后当收到成功响应时,该应用会显示 "here is the requested data and updated context (updated page / sort status)"。

另一个问题是请求失败。在 optimisitic/useEffect 方法中,我现在需要恢复在尝试为这些状态更改加载数据之前更新的分页和排序状态。这种复杂性随着依赖项的数量而增加。另外,这是关键(再一次,如果我没记错的话)恢复这些状态将导致另一次获取(可能再次失败)。在悲观的方法中,您只需在发生错误时设置 loading = false 并且不会进行其他状态更改,因为分页和排序仅在成功接收数据时更新。数据以悲观的方式(不乐观)保持同步,这就是为什么我认为博客和视频在使用 useEffect 时说 "think in terms of synchronization" 很讽刺。

还有一件事,比如从 useEffect 中调用的 API(在其他一些示例中)实际上是在服务器端更改某些内容。在那种情况下,当使用 useEffect 并应用它时,乐观的方法只是在欺骗用户。该应用程序显示 "Ok, I updated that"。也许不吧。如果没有(如果有错误)希望有状态更新来恢复对用户说谎的状态更改,并以某种方式避免在恢复该状态时进行额外的 API 调用。希望用户看到状态变回并希望有一条消息......即便如此,这是一个好的用户体验吗?

我并不是说乐观渲染总是不好的。我是说 "I like being pessimistic most of the time. How do I do that while using useEffect?" :)

似乎 useEffect 正在被用于大多数应用程序的副作用(很多)。我经常不觉得它有用。我是一个悲观主义者,希望有替代方案。

按照你描述的方式,你的组件的其余部分的结构就像

[state, setState] => useState(initialState)
[paging, setPaging] => useState(initialPaging)
[sorting, setSorting] => useState(initialSort)

useEffect(() => {
  fetch(`https://some-random-api.com/data/page/{paging}/sort/{sorting}`)
    .then(response => response.json())
    .then(data => setState({
      data
    }));
}, [paging, sorting]);

return <div>
    <span>Current page is {paging}</span>
    <Button onClick={() => setPaging(page => page + 1)}>Nav Next</Button>
    <Button onClick={() => setPaging(page => page - 1)}>Nav Prev</Button>
    // ... and so on and so forth
</div>

并且当您单击 Nav Next 或 Prev 时,您不希望分页更新,直到它触发的效果被解决。如果抓取失败,您不希望用户看到分页从您需要执行的任何清理操作中起起落落。

为了实现这一点,您不能通过具有中间 "hopeful" 或 "pending" 状态值来延迟设置 "forward facing" 值吗?例如,这行得通吗?

[state, setState] => useState(initialState)

// Component state values
[paging, setPaging] => useState(initialPaging)
[sorting, setSorting] => useState(initialSort)

// "Optimist's" state values
[pendingPaging, setPendingPaging] => useState(paging)
[pendingSorting, setPendingSorting] => useState(sorting)

useEffect(() => {
  // checks if current state values differ from pending,
  // meaning app wants to do something
  // If pending and "actual" values match, do nothing
  if( paging !== pendingPaging
    || sorting !== pendingSorting
  ){
      fetch(`https://some-random-api.com/data/page/{paging}/sort/{sorting}`)
        .then(response => response.json())
        .then(data => {
          // things worked, updated component state to match pending
          setPaging(pendingPaging);
          setSorting(pendingSorting);
          setState({data});
        })
        .catch(err => {
          // ugh, I knew things would fail. Let me reset my hopes
          setPendingPaging(paging);
          setpendingSorting(sorting);
          setState({data});
        });
  }
}, [pendingPaging, pendingSorting, paging, sorting, setState]);

return <div>

    // show user "actual" page number
    <span>Current page is {paging}</span>

    // but update only pending values in response to events
    <Button onClick={() => setPendingPaging(page + 1)}>Nav Next</Button>
    <Button onClick={() => setPendingPaging(page - 1)}>Nav Prev</Button>

    // ... and so on and so forth
</div>

这样你就可以设置你希望的状态,做一些事情,然后设置你的状态和期望相匹配。如果成功,显示的状态会更新以匹配乐观值,如果失败,未决值将设置回现实。

我看到的最大问题是,当异步调用解决时,会同时进行多个 setState 调用。 React 应该 batch these calls,但您可能希望查找在单个调用中处理此问题的其他方法。

无论哪种方式,待定值和实际值都相等,因此虽然 useEffect 会触发,但不会 运行 另一次提取。

编辑 1 感谢您的积极反馈,很高兴您喜欢这个概念。我同意复杂性可能会变得笨拙,但我认为你可以建立一个工厂 class 来更清楚地管理事情。

例如,您可以使用最终输出 "actual" 状态、"hopeful" 状态和至少一个 usePessimisticEffectmakePessimistic class打回来。 class 可能看起来像:

class pessimistFactory {
  constructor(){
    this.state = {};
    this.effects = [];
  }
  // util from https://dzone.com/articles/how-to-capitalize-the-first-letter-of-a-string-in
  ucfirst = (string) => string.charAt(0).toUpperCase() + string.slice(1);

  registerState(param, initialVal){
    this.state[param] = initialVal;
  }

  makePessimisticEffect = (callback, dependencies) => useEffect(() => {
    async function handlePessimistically(){
      try {
        const result = await callback(...dependencies);
        // update theState with result
        setState({...hopefulState, result})
      }catch(err){
        // reset hopefulState and handle error
        setHopefulState({...theState})
      }
    }

    // Do something if some action pending
    if(/* pending and current state not the same */) handlePessimistically();

  }, [callback, dependencies]);

  registerEffect(callback, dependencies = []){
    this.effects.push(
      this.makePessimisticEffect(callback, dependencies)
    );
  }

  makeHooks = () => {
    const initialState = useState(this.state);
    return {
      theState: initialState,
      hopefulState: initialState,
      effects: this.effects
    }
  }
}

在实践中:

// Make factory instance
const factory = new pessimistFactory();

// Register state and effects
factory.registerState('paging', initialPaging)
factory.registerState('sorting', initialSorting)
factory.registerEffect('fetchData', () => fetch(`https://some-random-api.com/data/page/{paging}/sort/{sorting}`))

// Make the hooks
const {theState, hopefulState, effects} = factory.makeHooks();

// Implement hooks in component
const SomeComponent = () => {
  const [state, setState] = theState();
  const [pendingState, setPendingState] = hopefulState();

  effects.forEach(useEffect => useEffect());

  return <div>displayed stuff</div>
}

最棘手的部分是设置 makePessimisticEffect 效果来读取和处理由生成的 setState 创建的值和回调,我意识到如果在我编写它时完全损坏。

我现在没有时间真正弄清楚细节,但我认为可以通过 useCallback.

等可用的新挂钩来实现

不过,我确实喜欢这个想法,所以我会在有空的时候尝试解决这个问题。如果你在那之前建立了一个工作工厂 class,或者如果有人有更好的完全不同的解决方案,我会非常有兴趣看到它。