-
Notifications
You must be signed in to change notification settings - Fork 50
refactor: replace useActionState with useTransition for team role actions #2024
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -1,7 +1,7 @@ | ||||||||||||||||||||||
| "use client"; | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| import { MoreHorizontal, Search } from "lucide-react"; | ||||||||||||||||||||||
| import { useActionState, useCallback, useState } from "react"; | ||||||||||||||||||||||
| import { useCallback, useState, useTransition } from "react"; | ||||||||||||||||||||||
| import { FreeTag } from "@/components/free-tag"; | ||||||||||||||||||||||
| import { ProTag } from "@/components/pro-tag"; | ||||||||||||||||||||||
| import { | ||||||||||||||||||||||
|
|
@@ -169,9 +169,10 @@ function UserTeamsItem({ | |||||||||||||||||||||
| teamId={teamId} | ||||||||||||||||||||||
| userId={currentUserId} | ||||||||||||||||||||||
| role={role} | ||||||||||||||||||||||
| renderButton={(isPending) => ( | ||||||||||||||||||||||
| renderButton={({ isPending, handleClick }) => ( | ||||||||||||||||||||||
| <button | ||||||||||||||||||||||
| type="submit" | ||||||||||||||||||||||
| type="button" | ||||||||||||||||||||||
| onClick={handleClick} | ||||||||||||||||||||||
| className="flex items-center w-full px-3 py-2 text-left text-[14px] leading-[16px] hover:bg-white/5 text-white-400 rounded-md" | ||||||||||||||||||||||
| disabled={isPending} | ||||||||||||||||||||||
| > | ||||||||||||||||||||||
|
|
@@ -186,9 +187,10 @@ function UserTeamsItem({ | |||||||||||||||||||||
| teamId={teamId} | ||||||||||||||||||||||
| userId={currentUserId} | ||||||||||||||||||||||
| role={role} | ||||||||||||||||||||||
| renderButton={(isPending) => ( | ||||||||||||||||||||||
| renderButton={({ isPending, handleClick }) => ( | ||||||||||||||||||||||
| <button | ||||||||||||||||||||||
| type="submit" | ||||||||||||||||||||||
| type="button" | ||||||||||||||||||||||
| onClick={handleClick} | ||||||||||||||||||||||
| className="flex items-center w-full px-3 py-2 text-left text-[14px] leading-[16px] hover:bg-white/5 text-white-400 rounded-md" | ||||||||||||||||||||||
| disabled={isPending} | ||||||||||||||||||||||
| > | ||||||||||||||||||||||
|
|
@@ -204,9 +206,10 @@ function UserTeamsItem({ | |||||||||||||||||||||
| teamId={teamId} | ||||||||||||||||||||||
| userId={currentUserId} | ||||||||||||||||||||||
| role={role} | ||||||||||||||||||||||
| renderButton={(isPending) => ( | ||||||||||||||||||||||
| renderButton={({ isPending, handleClick }) => ( | ||||||||||||||||||||||
| <button | ||||||||||||||||||||||
| type="submit" | ||||||||||||||||||||||
| type="button" | ||||||||||||||||||||||
| onClick={handleClick} | ||||||||||||||||||||||
| className="flex items-center w-full px-3 py-2 font-medium text-[14px] leading-[16px] text-error-900 hover:bg-error-900/20 rounded-md" | ||||||||||||||||||||||
| disabled={isPending} | ||||||||||||||||||||||
| > | ||||||||||||||||||||||
|
|
@@ -226,18 +229,23 @@ interface ChangeTeamAndActionProps { | |||||||||||||||||||||
| teamId: string; | ||||||||||||||||||||||
| userId: string; | ||||||||||||||||||||||
| role: string; | ||||||||||||||||||||||
| renderButton: (isPending: boolean) => React.ReactNode; | ||||||||||||||||||||||
| renderButton: (options: { | ||||||||||||||||||||||
| isPending: boolean; | ||||||||||||||||||||||
| handleClick: () => void; | ||||||||||||||||||||||
| }) => React.ReactNode; | ||||||||||||||||||||||
| action: () => Promise<void>; | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
| function ChangeTeamAndAction({ | ||||||||||||||||||||||
| renderButton, | ||||||||||||||||||||||
| action, | ||||||||||||||||||||||
| }: ChangeTeamAndActionProps) { | ||||||||||||||||||||||
| const [_state, formAction, isPending] = useActionState(action, null); | ||||||||||||||||||||||
| const [isPending, startTransition] = useTransition(); | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| return ( | ||||||||||||||||||||||
| <form className="w-full" action={formAction}> | ||||||||||||||||||||||
| {renderButton(isPending)} | ||||||||||||||||||||||
| </form> | ||||||||||||||||||||||
| ); | ||||||||||||||||||||||
| const handleClick = useCallback(() => { | ||||||||||||||||||||||
| startTransition(async () => { | ||||||||||||||||||||||
| await action(); | ||||||||||||||||||||||
|
Comment on lines
+245
to
+246
|
||||||||||||||||||||||
| startTransition(async () => { | |
| await action(); | |
| action().catch((error) => { | |
| // Optionally, handle error here or propagate | |
| console.error("Error in action:", error); |
Copilot
AI
Oct 17, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Using async/await inside startTransition can lead to timing issues where the pending state is cleared before the async operation completes. The transition will end as soon as the async function returns its promise, not when the promise resolves. Consider wrapping the action call directly: startTransition(() => { action(); }) or handle the promise resolution explicitly if you need to track completion.
| startTransition(async () => { | |
| await action(); | |
| startTransition(() => { | |
| action(); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Critical: startTransition cannot properly handle async callbacks.
React's startTransition expects a synchronous callback. When you pass an async function, startTransition returns immediately without waiting for the promise, causing isPending to become false before the action completes. This defeats the purpose of tracking the pending state—buttons will not remain disabled during the async operation.
Wrap the async work synchronously:
const handleClick = useCallback(() => {
- startTransition(async () => {
- await action();
- });
+ startTransition(() => {
+ void action();
+ });
}, [action]);Note: void suppresses the floating promise warning. If you need to handle errors, add a try-catch inside the action or chain .catch().
📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| const handleClick = useCallback(() => { | |
| startTransition(async () => { | |
| await action(); | |
| }); | |
| }, [action]); | |
| const handleClick = useCallback(() => { | |
| startTransition(() => { | |
| void action(); | |
| }); | |
| }, [action]); |
🤖 Prompt for AI Agents
In apps/studio.giselles.ai/app/(main)/settings/account/user-teams.tsx around
lines 244 to 248, the current use of startTransition passes an async callback
which returns a promise immediately and causes isPending to flip false before
the async work completes; change it to pass a synchronous callback that calls
the async action without awaiting (e.g., call void action() inside the
startTransition callback) so startTransition properly tracks the transition; if
you need error handling, handle it inside the action or attach a
.catch()/try-catch within the async function rather than awaiting inside
startTransition.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Switching
ChangeTeamAndActiontouseTransitionremoved the<form>submission that previously triggered Next.js’s automatic refresh after a server action. The new implementation just callsaction()insidestartTransitionand never refreshes or updates local state. For theLeave teamflow this meansleaveTeamruns on the server (and even callsrevalidatePath("/settings/account")), but the client list never re-renders and still shows the old team until the page is manually reloaded. Consider invokingrouter.refresh()(or otherwise mutating local state) after the promise resolves so the UI reflects the updated membership immediately.Useful? React with 👍 / 👎.