useTransition

useTransition 是一个 React Hook,它允许你在后台渲染 UI 的一部分。

const [isPending, startTransition] = useTransition()

参考

🌐 Reference

useTransition()

在组件的顶层调用 useTransition 来将某些状态更新标记为过渡。

🌐 Call useTransition at the top level of your component to mark some state updates as Transitions.

import { useTransition } from 'react';

function TabContainer() {
const [isPending, startTransition] = useTransition();
// ...
}

查看更多示例。

参数

🌐 Parameters

useTransition 不接受任何参数。

返回

🌐 Returns

useTransition 返回一个正好包含两个项的数组:

  1. isPending 标志,用于指示是否有待处理的过渡。
  2. startTransition 函数 允许你将更新标记为过渡。

startTransition(action)

useTransition 返回的 startTransition 函数允许你将更新标记为过渡。

🌐 The startTransition function returned by useTransition lets you mark an update as a Transition.

function TabContainer() {
const [isPending, startTransition] = useTransition();
const [tab, setTab] = useState('about');

function selectTab(nextTab) {
startTransition(() => {
setTab(nextTab);
});
}
// ...
}

注意

startTransition 中调用的函数被称为“动作”。

🌐 Functions called in startTransition are called “Actions”.

传递给 startTransition 的函数被称为“Action”。按照惯例,任何在 startTransition 中调用的回调(例如回调属性)都应命名为 action 或包含“Action”后缀:

🌐 The function passed to startTransition is called an “Action”. By convention, any callback called inside startTransition (such as a callback prop) should be named action or include the “Action” suffix:

function SubmitButton({ submitAction }) {
const [isPending, startTransition] = useTransition();

return (
<button
disabled={isPending}
onClick={() => {
startTransition(async () => {
await submitAction();
});
}}
>
Submit
</button>
);
}

参数

🌐 Parameters

  • action:一个通过调用一个或多个set 函数来更新某些状态的函数。React 会立即以无参数的方式调用action,并将action函数调用期间同步调度的所有状态更新标记为过渡。action中等待的任何异步调用也将包含在过渡中,但目前在await之后的任何set函数都需要额外封装一个startTransition(参见故障排除)。被标记为过渡的状态更新将是非阻塞的并且不会显示不必要的加载指示器

返回

🌐 Returns

startTransition 不返回任何值。

注意事项

🌐 Caveats

  • useTransition 是一个 Hook,所以它只能在组件或自定义 Hook 内部调用。如果你需要在其他地方启动一个 Transition(例如,从数据库中),请改为调用独立的 startTransition
  • 只有在你可以访问该状态的 set 函数时,才能将更新封装到 Transition 中。如果你想根据某个 prop 或自定义 Hook 的值来启动 Transition,请尝试使用 useDeferredValue
  • 你传递给 startTransition 的函数会立即被调用,标记在其执行期间发生的所有状态更新为过渡(Transitions)。例如,如果你尝试在 setTimeout 中执行状态更新,它们将不会被标记为过渡(Transitions)。
  • 你必须将任何异步请求后的状态更新封装在另一个 startTransition 中,以将它们标记为过渡。这是一个已知的限制,我们将在未来修复(参见 故障排除)。
  • startTransition 函数具有稳定的标识,因此你经常会看到它在 Effect 依赖中被省略,但包括它不会导致 Effect 被触发。如果 linter 允许你在没有错误的情况下省略依赖,那么这样做是安全的。了解更多关于移除 Effect 依赖的信息。
  • 标记为过渡的状态更新会被其他状态更新中断。例如,如果你在过渡中更新一个图表组件,但随后在图表重新渲染期间开始在输入框中输入内容,React 会在处理输入更新后重新启动图表组件的渲染工作。
  • 转场更新不能用于控制文本输入。
  • 如果有多个正在进行的过渡,React 当前会将它们一起批处理。这是一个可能在未来版本中被移除的限制。

用法

🌐 Usage

使用 Actions 执行非阻塞更新

🌐 Perform non-blocking updates with Actions

在组件顶部调用 useTransition 来创建 Actions,并访问挂起状态:

🌐 Call useTransition at the top of your component to create Actions, and access the pending state:

import {useState, useTransition} from 'react';

function CheckoutForm() {
const [isPending, startTransition] = useTransition();
// ...
}

useTransition 返回一个正好包含两个项的数组:

  1. 用于指示是否有待处理过渡的isPending标志。
  2. The startTransition 函数 让你创建一个操作。

要启动转换,请像这样将一个函数传递给 startTransition

🌐 To start a Transition, pass a function to startTransition like this:

import {useState, useTransition} from 'react';
import {updateQuantity} from './api';

function CheckoutForm() {
const [isPending, startTransition] = useTransition();
const [quantity, setQuantity] = useState(1);

function onSubmit(newQuantity) {
startTransition(async function () {
const savedQuantity = await updateQuantity(newQuantity);
startTransition(() => {
setQuantity(savedQuantity);
});
});
}
// ...
}

传递给 startTransition 的函数被称为“动作”。你可以在一个动作中更新状态并(可选地)执行副作用,这些工作将在后台完成,而不会阻塞页面上的用户交互。一个过渡可以包含多个动作,并且在过渡进行时,你的 UI 会保持响应。例如,如果用户点击一个标签,但随后改变主意又点击了另一个标签,第二次点击将会立即被处理,而无需等待第一次更新完成。

🌐 The function passed to startTransition is called the “Action”. You can update state and (optionally) perform side effects within an Action, and the work will be done in the background without blocking user interactions on the page. A Transition can include multiple Actions, and while a Transition is in progress, your UI stays responsive. For example, if the user clicks a tab but then changes their mind and clicks another tab, the second click will be immediately handled without waiting for the first update to finish.

为了向用户反馈正在进行中的过渡,isPending 状态在第一次调用 startTransition 时切换到 true,并保持 true 直到所有操作完成并向用户显示最终状态。过渡确保操作中的副作用按顺序完成,以防止不需要的加载指示器,并且在过渡进行时,可以使用 useOptimistic 提供即时反馈。

🌐 To give the user feedback about in-progress Transitions, the isPending state switches to true at the first call to startTransition, and stays true until all Actions complete and the final state is shown to the user. Transitions ensure side effects in Actions to complete in order to prevent unwanted loading indicators, and you can provide immediate feedback while the Transition is in progress with useOptimistic.

The difference between Actions and regular event handling

例子 1 of 2:
在操作中更新数量

🌐 Updating the quantity in an Action

在这个例子中,updateQuantity 函数模拟向服务器发送请求以更新购物车中商品的数量。这个函数被人为地减慢,以确保完成请求至少需要一秒钟。

🌐 In this example, the updateQuantity function simulates a request to the server to update the item’s quantity in the cart. This function is artificially slowed down so that it takes at least a second to complete the request.

快速多次更新数量。请注意,当任何请求正在进行时,会显示待处理的“总计”状态,并且“总计”只有在最终请求完成后才会更新。因为更新是在一个操作中进行的,所以在请求进行时“数量”可以继续更新。

🌐 Update the quantity multiple times quickly. Notice that the pending “Total” state is shown while any requests are in progress, and the “Total” updates only after the final request is complete. Because the update is in an Action, the “quantity” can continue to be updated while the request is in progress.

import { useState, useTransition } from "react";
import { updateQuantity } from "./api";
import Item from "./Item";
import Total from "./Total";

export default function App({}) {
  const [quantity, setQuantity] = useState(1);
  const [isPending, startTransition] = useTransition();

  const updateQuantityAction = async newQuantity => {
    // To access the pending state of a transition,
    // call startTransition again.
    startTransition(async () => {
      const savedQuantity = await updateQuantity(newQuantity);
      startTransition(() => {
        setQuantity(savedQuantity);
      });
    });
  };

  return (
    <div>
      <h1>Checkout</h1>
      <Item action={updateQuantityAction}/>
      <hr />
      <Total quantity={quantity} isPending={isPending} />
    </div>
  );
}

这是一个基本示例,用于演示操作的工作原理,但此示例无法处理请求的完成顺序。当多次更新数量时,之前的请求可能会在后续请求完成之后才完成,从而导致数量更新顺序混乱。这是一个已知的限制,我们将在未来修复(参见下面的故障排除)。

🌐 This is a basic example to demonstrate how Actions work, but this example does not handle requests completing out of order. When updating the quantity multiple times, it’s possible for the previous requests to finish after later requests causing the quantity to update out of order. This is a known limitation that we will fix in the future (see Troubleshooting below).

对于常见用例,React 提供内置抽象,例如:

🌐 For common use cases, React provides built-in abstractions such as:

这些解决方案会为你处理请求排序。当使用 Transitions 来构建自己的自定义钩子或管理异步状态转换的库时,你可以更好地控制请求排序,但必须自己处理它。

🌐 These solutions handle request ordering for you. When using Transitions to build your own custom hooks or libraries that manage async state transitions, you have greater control over the request ordering, but you must handle it yourself.


从组件中暴露 action 属性

🌐 Exposing action prop from components

你可以从组件中暴露一个 action 属性,以允许父组件调用一个动作。

🌐 You can expose an action prop from a component to allow a parent to call an Action.

例如,这个 TabButton 组件将它的 onClick 逻辑封装在一个 action 属性中:

🌐 For example, this TabButton component wraps its onClick logic in an action prop:

export default function TabButton({ action, children, isActive }) {
const [isPending, startTransition] = useTransition();
if (isActive) {
return <b>{children}</b>
}
return (
<button onClick={() => {
startTransition(async () => {
// await the action that's passed in.
// This allows it to be either sync or async.
await action();
});
}}>
{children}
</button>
);
}

因为父组件在 action 内更新了它的状态,所以该状态更新被标记为一个过渡(Transition)。这意味着你可以点击“帖子”,然后立即点击“联系方式”,而不会阻塞用户交互:

🌐 Because the parent component updates its state inside the action, that state update gets marked as a Transition. This means you can click on “Posts” and then immediately click “Contact” and it does not block user interactions:

import { useTransition } from 'react';

export default function TabButton({ action, children, isActive }) {
  const [isPending, startTransition] = useTransition();
  if (isActive) {
    return <b>{children}</b>
  }
  if (isPending) {
    return <b className="pending">{children}</b>;
  }
  return (
    <button onClick={async () => {
      startTransition(async () => {
        // await the action that's passed in.
        // This allows it to be either sync or async.
        await action();
      });
    }}>
      {children}
    </button>
  );
}

注意

当从组件中暴露 action 属性时,你应该在过渡中 await 它。

🌐 When exposing an action prop from a component, you should await it inside the transition.

这允许 action 回调可以是同步的也可以是异步的,而无需额外的 startTransition 来在操作中封装 await

🌐 This allows the action callback to be either synchronous or asynchronous without requiring an additional startTransition to wrap the await in the action.


显示待处理的视觉状态

🌐 Displaying a pending visual state

你可以使用 useTransition 返回的 isPending 布尔值来向用户指示过渡正在进行。例如,标签按钮可以具有特殊的“等待中”视觉状态:

🌐 You can use the isPending boolean value returned by useTransition to indicate to the user that a Transition is in progress. For example, the tab button can have a special “pending” visual state:

function TabButton({ action, children, isActive }) {
const [isPending, startTransition] = useTransition();
// ...
if (isPending) {
return <b className="pending">{children}</b>;
}
// ...

注意现在点击“帖子”时感觉更灵敏,因为标签按钮本身会立即更新:

🌐 Notice how clicking “Posts” now feels more responsive because the tab button itself updates right away:

import { useTransition } from 'react';

export default function TabButton({ action, children, isActive }) {
  const [isPending, startTransition] = useTransition();
  if (isActive) {
    return <b>{children}</b>
  }
  if (isPending) {
    return <b className="pending">{children}</b>;
  }
  return (
    <button onClick={() => {
      startTransition(async () => {
        await action();
      });
    }}>
      {children}
    </button>
  );
}


防止不需要的加载指示器

🌐 Preventing unwanted loading indicators

在此示例中,PostsTab 组件使用 use 获取一些数据。当你点击“Posts”标签时,PostsTab 组件会挂起,导致最近的加载占位符出现:

🌐 In this example, the PostsTab component fetches some data using use. When you click the “Posts” tab, the PostsTab component suspends, causing the closest loading fallback to appear:

import { Suspense, useState } from 'react';
import TabButton from './TabButton.js';
import AboutTab from './AboutTab.js';
import PostsTab from './PostsTab.js';
import ContactTab from './ContactTab.js';

export default function TabContainer() {
  const [tab, setTab] = useState('about');
  return (
    <Suspense fallback={<h1>🌀 Loading...</h1>}>
      <TabButton
        isActive={tab === 'about'}
        action={() => setTab('about')}
      >
        About
      </TabButton>
      <TabButton
        isActive={tab === 'posts'}
        action={() => setTab('posts')}
      >
        Posts
      </TabButton>
      <TabButton
        isActive={tab === 'contact'}
        action={() => setTab('contact')}
      >
        Contact
      </TabButton>
      <hr />
      {tab === 'about' && <AboutTab />}
      {tab === 'posts' && <PostsTab />}
      {tab === 'contact' && <ContactTab />}
    </Suspense>
  );
}

隐藏整个选项卡容器以显示加载指示器会导致令人不适的用户体验。如果你将 useTransition 添加到 TabButton,你可以改为在选项卡按钮中显示等待状态。

🌐 Hiding the entire tab container to show a loading indicator leads to a jarring user experience. If you add useTransition to TabButton, you can instead display the pending state in the tab button instead.

请注意,点击“帖子”不再用加载指示器替换整个标签容器:

🌐 Notice that clicking “Posts” no longer replaces the entire tab container with a spinner:

import { useTransition } from 'react';

export default function TabButton({ action, children, isActive }) {
  const [isPending, startTransition] = useTransition();
  if (isActive) {
    return <b>{children}</b>
  }
  if (isPending) {
    return <b className="pending">{children}</b>;
  }
  return (
    <button onClick={() => {
      startTransition(async () => {
        await action();
      });
    }}>
      {children}
    </button>
  );
}

了解有关在 Suspense 中使用过渡的更多信息。

注意

过渡只会“等待”足够长的时间以避免隐藏已显示的内容(例如标签容器)。如果帖子标签有一个嵌套的 <Suspense> 边界, 过渡将不会为其“等待”。

🌐 Transitions only “wait” long enough to avoid hiding already revealed content (like the tab container). If the Posts tab had a nested <Suspense> boundary, the Transition would not “wait” for it.


构建一个启用 Suspense 的路由

🌐 Building a Suspense-enabled router

如果你正在构建 React 框架或路由,我们建议将页面导航标记为 Transitions。

🌐 If you’re building a React framework or a router, we recommend marking page navigations as Transitions.

function Router() {
const [page, setPage] = useState('/');
const [isPending, startTransition] = useTransition();

function navigate(url) {
startTransition(() => {
setPage(url);
});
}
// ...

建议这样做有三个原因:

🌐 This is recommended for three reasons:

这是一个使用转换进行导航的简化路由示例。

🌐 Here is a simplified router example using Transitions for navigations.

import { Suspense, useState, useTransition } from 'react';
import IndexPage from './IndexPage.js';
import ArtistPage from './ArtistPage.js';
import Layout from './Layout.js';

export default function App() {
  return (
    <Suspense fallback={<BigSpinner />}>
      <Router />
    </Suspense>
  );
}

function Router() {
  const [page, setPage] = useState('/');
  const [isPending, startTransition] = useTransition();

  function navigate(url) {
    startTransition(() => {
      setPage(url);
    });
  }

  let content;
  if (page === '/') {
    content = (
      <IndexPage navigate={navigate} />
    );
  } else if (page === '/the-beatles') {
    content = (
      <ArtistPage
        artist={{
          id: 'the-beatles',
          name: 'The Beatles',
        }}
      />
    );
  }
  return (
    <Layout isPending={isPending}>
      {content}
    </Layout>
  );
}

function BigSpinner() {
  return <h2>🌀 Loading...</h2>;
}

注意

启用 Suspense 的 路由预计默认会将导航更新封装到过渡中。


向用户显示带有错误边界的错误

🌐 Displaying an error to users with an error boundary

如果传递给 startTransition 的函数抛出错误,你可以使用 错误边界 向用户显示错误。要使用错误边界,请将调用 useTransition 的组件封装在错误边界中。一旦传递给 startTransition 的函数出错,错误边界的备用内容将会显示。

🌐 If a function passed to startTransition throws an error, you can display an error to your user with an error boundary. To use an error boundary, wrap the component where you are calling the useTransition in an error boundary. Once the function passed to startTransition errors, the fallback for the error boundary will be displayed.

import { useTransition } from "react";
import { ErrorBoundary } from "react-error-boundary";

export function AddCommentContainer() {
  return (
    <ErrorBoundary fallback={<p>⚠️Something went wrong</p>}>
      <AddCommentButton />
    </ErrorBoundary>
  );
}

function addComment(comment) {
  // For demonstration purposes to show Error Boundary
  if (comment == null) {
    throw new Error("Example Error: An error thrown to trigger error boundary");
  }
}

function AddCommentButton() {
  const [pending, startTransition] = useTransition();

  return (
    <button
      disabled={pending}
      onClick={() => {
        startTransition(() => {
          // Intentionally not passing a comment
          // so error gets thrown
          addComment();
        });
      }}
    >
      Add comment
    </button>
  );
}


故障排除

🌐 Troubleshooting

在 Transition 中更新输入不起作用

🌐 Updating an input in a Transition doesn’t work

你不能将转换用于控制输入的状态变量:

🌐 You can’t use a Transition for a state variable that controls an input:

const [text, setText] = useState('');
// ...
function handleChange(e) {
// ❌ Can't use Transitions for controlled input state
startTransition(() => {
setText(e.target.value);
});
}
// ...
return <input value={text} onChange={handleChange} />;

这是因为过渡(Transitions)是非阻塞的,但在响应 change 事件时更新输入应该是同步进行的。如果你想在输入时运行过渡,你有两种选择:

🌐 This is because Transitions are non-blocking, but updating an input in response to the change event should happen synchronously. If you want to run a Transition in response to typing, you have two options:

  1. 你可以声明两个独立的状态变量:一个用于输入状态(它总是同步更新),另一个你将在 Transition 中更新。这让你可以使用同步状态来控制输入,并将 Transition 状态变量(它将会“落后于”输入)传递到其余的渲染逻辑中。
  2. 或者,你可以有一个状态变量,并添加useDeferredValue,它将“落后于”真实值。它会触发非阻塞的重新渲染,以自动“跟上”新值。

React 不会将我的状态更新视为转换

🌐 React doesn’t treat my state update as a Transition

当你将状态更新封装在 Transition 中时,确保它发生在 startTransition 调用期间:

🌐 When you wrap a state update in a Transition, make sure that it happens during the startTransition call:

startTransition(() => {
// ✅ Setting state *during* startTransition call
setPage('/about');
});

传递给 startTransition 的函数必须是同步的。你不能像这样将更新标记为过渡:

🌐 The function you pass to startTransition must be synchronous. You can’t mark an update as a Transition like this:

startTransition(() => {
// ❌ Setting state *after* startTransition call
setTimeout(() => {
setPage('/about');
}, 1000);
});

而是,你可以这样做:

🌐 Instead, you could do this:

setTimeout(() => {
startTransition(() => {
// ✅ Setting state *during* startTransition call
setPage('/about');
});
}, 1000);

React 不会将我在 await 之后的状态更新视为一个过渡

🌐 React doesn’t treat my state update after await as a Transition

当你在 startTransition 函数中使用 await 时,await 之后发生的状态更新不会被标记为过渡。你必须在每个 await 之后将状态更新封装在 startTransition 调用中:

🌐 When you use await inside a startTransition function, the state updates that happen after the await are not marked as Transitions. You must wrap state updates after each await in a startTransition call:

startTransition(async () => {
await someAsyncFunction();
// ❌ Not using startTransition after await
setPage('/about');
});

但是,改成这样是有效的:

🌐 However, this works instead:

startTransition(async () => {
await someAsyncFunction();
// ✅ Using startTransition *after* await
startTransition(() => {
setPage('/about');
});
});

这是一个由于 React 丢失异步上下文范围而导致的 JavaScript 限制。将来,当 AsyncContext 可用时,这个限制将被移除。

🌐 This is a JavaScript limitation due to React losing the scope of the async context. In the future, when AsyncContext is available, this limitation will be removed.


我想从组件外部调用 useTransition

🌐 I want to call useTransition from outside a component

你不能在组件外部调用 useTransition,因为它是一个 Hook。在这种情况下,请改用独立的 startTransition 方法。它的工作方式相同,但不会提供 isPending 指示器。

🌐 You can’t call useTransition outside a component because it’s a Hook. In this case, use the standalone startTransition method instead. It works the same way, but it doesn’t provide the isPending indicator.


我传递给 startTransition 的函数会立即执行

🌐 The function I pass to startTransition executes immediately

如果运行此代码,它将打印 1、2、3:

🌐 If you run this code, it will print 1, 2, 3:

console.log(1);
startTransition(() => {
console.log(2);
setPage('/about');
});
console.log(3);

预计会打印 1, 2, 3。 你传递给 startTransition 的函数不会被延迟。与浏览器的 setTimeout 不同,它不会稍后运行回调。React 会立即执行你的函数,但在函数运行期间安排的任何状态更新都会被标记为过渡。你可以想象它是这样工作的:

// A simplified version of how React works

let isInsideTransition = false;

function startTransition(scope) {
isInsideTransition = true;
scope();
isInsideTransition = false;
}

function setState() {
if (isInsideTransition) {
// ... schedule a Transition state update ...
} else {
// ... schedule an urgent state update ...
}
}

我的 Transitions 中的状态更新无序

🌐 My state updates in Transitions are out of order

如果你在 startTransition 里面 await,你可能会看到更新发生的顺序混乱。

🌐 If you await inside startTransition, you might see the updates happen out of order.

在这个例子中,updateQuantity 函数模拟了向服务器请求以更新购物车中商品数量的操作。该函数在之前的请求之后人为地每隔一次返回请求,以模拟网络请求的竞态条件。

🌐 In this example, the updateQuantity function simulates a request to the server to update the item’s quantity in the cart. This function artificially returns every other request after the previous to simulate race conditions for network requests.

尝试先更新一次数量,然后快速多次更新它。你可能会看到不正确的总数:

🌐 Try updating the quantity once, then update it quickly multiple times. You might see the incorrect total:

import { useState, useTransition } from "react";
import { updateQuantity } from "./api";
import Item from "./Item";
import Total from "./Total";

export default function App({}) {
  const [quantity, setQuantity] = useState(1);
  const [isPending, startTransition] = useTransition();
  // Store the actual quantity in separate state to show the mismatch.
  const [clientQuantity, setClientQuantity] = useState(1);

  const updateQuantityAction = newQuantity => {
    setClientQuantity(newQuantity);

    // Access the pending state of the transition,
    // by wrapping in startTransition again.
    startTransition(async () => {
      const savedQuantity = await updateQuantity(newQuantity);
      startTransition(() => {
        setQuantity(savedQuantity);
      });
    });
  };

  return (
    <div>
      <h1>Checkout</h1>
      <Item action={updateQuantityAction}/>
      <hr />
      <Total clientQuantity={clientQuantity} savedQuantity={quantity} isPending={isPending} />
    </div>
  );
}

当多次点击时,之前的请求可能会在后来的请求之后完成。当这种情况发生时,React 当前无法知道预期的顺序。这是因为更新是异步调度的,并且 React 会在异步边界之间失去顺序的上下文。

🌐 When clicking multiple times, it’s possible for previous requests to finish after later requests. When this happens, React currently has no way to know the intended order. This is because the updates are scheduled asynchronously, and React loses context of the order across the async boundary.

这是预期的,因为过渡中的动作(Actions)并不保证执行顺序。对于常见的使用场景,React 提供了更高级的抽象,比如 useActionState<form> actions,它们会为你处理顺序问题。对于高级用例,你需要自己实现排队和中止逻辑来处理这个问题。

🌐 This is expected, because Actions within a Transition do not guarantee execution order. For common use cases, React provides higher-level abstractions like useActionState and <form> actions that handle ordering for you. For advanced use cases, you’ll need to implement your own queuing and abort logic to handle this.

useActionState 处理执行顺序的示例:

🌐 Example of useActionState handling execution order:

import { useState, useActionState } from "react";
import { updateQuantity } from "./api";
import Item from "./Item";
import Total from "./Total";

export default function App({}) {
  // Store the actual quantity in separate state to show the mismatch.
  const [clientQuantity, setClientQuantity] = useState(1);
  const [quantity, updateQuantityAction, isPending] = useActionState(
    async (prevState, payload) => {
      setClientQuantity(payload);
      const savedQuantity = await updateQuantity(payload);
      return savedQuantity; // Return the new quantity to update the state
    },
    1 // Initial quantity
  );

  return (
    <div>
      <h1>Checkout</h1>
      <Item action={updateQuantityAction}/>
      <hr />
      <Total clientQuantity={clientQuantity} savedQuantity={quantity} isPending={isPending} />
    </div>
  );
}