Profile and notification settings forms. AI output missed dirty state tracking, form reset after save, success toasts, and optimistic updates on toggles. The production version fixes all issues found in review.
1export function ProfileForm() {2 const dispatch = useAppDispatch();3 const { settings, saveStatus, error } = useAppSelector(4 (state) => state.settings5 );67 const {8 register,9 handleSubmit,10 formState: { errors },11 } = useForm<ProfileValues>({12 resolver: zodResolver(profileSchema),13 mode: "onBlur",14 defaultValues: {15 name: settings?.profile.name ?? "",16 bio: settings?.profile.bio ?? "",17 },18 });1920 useEffect(() => {21 dispatch(clearSettingsError());22 }, [dispatch]);2324 function onSubmit(data: ProfileValues) {25 dispatch(updateProfile({ name: data.name, bio: data.bio ?? "" }));26 }2728 const isSaving = saveStatus === "saving";2930 return (31 <form onSubmit={handleSubmit(onSubmit)} className="grid gap-6" noValidate>32 {error && saveStatus === "failed" && (33 <div role="alert" className="text-sm text-destructive">34 {error}35 </div>36 )}3738 <div className="grid gap-2">39 <Label htmlFor="name">Name</Label>40 <Input41 id="name"42 placeholder="Your name"43 disabled={isSaving}44 aria-invalid={!!errors.name}45 {...register("name")}46 />47 {errors.name && (48 <p className="text-sm text-destructive">49 {errors.name.message}50 </p>51 )}52 </div>5354 <div className="grid gap-2">55 <Label htmlFor="bio">Bio</Label>56 <textarea57 id="bio"58 placeholder="Tell us a little about yourself"59 disabled={isSaving}60 aria-invalid={!!errors.bio}61 {...register("bio")}62 />63 {errors.bio && (64 <p className="text-sm text-destructive">65 {errors.bio.message}66 </p>67 )}68 </div>6970 <div>71 <Button type="submit" disabled={isSaving}>72 {isSaving ? "Saving..." : "Save changes"}73 </Button>74 </div>75 </form>76 );77}1export function ProfileForm() {2 const dispatch = useAppDispatch();3 const { settings, saveStatus, error } = useAppSelector(4 (state) => state.settings5 );67 const {8 register,9 handleSubmit,10 reset,11 formState: { errors, isDirty },12 } = useForm<ProfileValues>({13 resolver: zodResolver(profileSchema),14 mode: "onBlur",15 defaultValues: {16 name: settings?.profile.name ?? "",17 bio: settings?.profile.bio ?? "",18 },19 });2021 // Reset form when settings are loaded/updated after a successful save22 useEffect(() => {23 if (settings && saveStatus === "succeeded") {24 reset({25 name: settings.profile.name,26 bio: settings.profile.bio,27 });28 toast.success("Profile updated.");29 dispatch(resetSaveStatus());30 }31 }, [settings, saveStatus, reset, dispatch]);3233 // Reset form defaults when settings first load34 useEffect(() => {35 if (settings) {36 reset({37 name: settings.profile.name,38 bio: settings.profile.bio,39 });40 }41 }, [settings?.profile.name, settings?.profile.bio, reset]);4243 useEffect(() => {44 dispatch(clearSettingsError());45 }, [dispatch]);4647 function onSubmit(data: ProfileValues) {48 dispatch(updateProfile({ name: data.name, bio: data.bio ?? "" }));49 }5051 const isSaving = saveStatus === "saving";5253 return (54 <form onSubmit={handleSubmit(onSubmit)} className="grid gap-6" noValidate>55 {error && saveStatus === "failed" && (56 <div role="alert" className="text-sm text-destructive">57 {error}58 </div>59 )}6061 <div className="grid gap-2">62 <Label htmlFor="name">Name</Label>63 <Input64 id="name"65 placeholder="Your name"66 disabled={isSaving}67 aria-invalid={!!errors.name}68 aria-describedby={errors.name ? "name-error" : undefined}69 {...register("name")}70 />71 {errors.name && (72 <p id="name-error" className="text-sm text-destructive">73 {errors.name.message}74 </p>75 )}76 </div>7778 <div className="grid gap-2">79 <Label htmlFor="bio">Bio</Label>80 <textarea81 id="bio"82 placeholder="Tell us a little about yourself"83 disabled={isSaving}84 aria-invalid={!!errors.bio}85 aria-describedby={errors.bio ? "bio-error" : undefined}86 {...register("bio")}87 />88 {errors.bio && (89 <p id="bio-error" className="text-sm text-destructive">90 {errors.bio.message}91 </p>92 )}93 </div>9495 <div>96 <Button type="submit" disabled={!isDirty || isSaving}>97 {isSaving ? (98 <span className="flex items-center gap-2">99 <svg className="h-4 w-4 animate-spin" aria-hidden="true" viewBox="0 0 24 24">100 <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />101 <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />102 </svg>103 Saving...104 </span>105 ) : (106 "Save changes"107 )}108 </Button>109 </div>110 </form>111 );112}1export function ProfileForm() {2 const dispatch = useAppDispatch();3 const { settings, saveStatus, error } = useAppSelector(4 (state) => state.settings5 );67 const {8 register,9 handleSubmit,10 formState: { errors },11 } = useForm<ProfileValues>({12 resolver: zodResolver(profileSchema),13 mode: "onBlur",14 defaultValues: {15 name: settings?.profile.name ?? "",16 bio: settings?.profile.bio ?? "",17 },18 });1920 useEffect(() => {21 dispatch(clearSettingsError());22 }, [dispatch]);2324 function onSubmit(data: ProfileValues) {25 dispatch(updateProfile({ name: data.name, bio: data.bio ?? "" }));26 }2728 const isSaving = saveStatus === "saving";2930 return (31 <form onSubmit={handleSubmit(onSubmit)} className="grid gap-6" noValidate>32 {error && saveStatus === "failed" && (33 <div role="alert" className="text-sm text-destructive">34 {error}35 </div>36 )}3738 <div className="grid gap-2">39 <Label htmlFor="name">Name</Label>40 <Input41 id="name"42 placeholder="Your name"43 disabled={isSaving}44 aria-invalid={!!errors.name}45 {...register("name")}46 />47 {errors.name && (48 <p className="text-sm text-destructive">49 {errors.name.message}50 </p>51 )}52 </div>5354 <div className="grid gap-2">55 <Label htmlFor="bio">Bio</Label>56 <textarea57 id="bio"58 placeholder="Tell us a little about yourself"59 disabled={isSaving}60 aria-invalid={!!errors.bio}61 {...register("bio")}62 />63 {errors.bio && (64 <p className="text-sm text-destructive">65 {errors.bio.message}66 </p>67 )}68 </div>6970 <div>71 <Button type="submit" disabled={isSaving}>72 {isSaving ? "Saving..." : "Save changes"}73 </Button>74 </div>75 </form>76 );77}The AI destructured formState but only pulled out errors. Without isDirty, the save button is always enabled even when the user has made no changes.
The button is disabled only when isSaving is true. It should also be disabled when the form is clean (!isDirty). Users can spam save with unchanged data.
After a successful save, the form's default values are not updated. isDirty stays true because react-hook-form still thinks the current values differ from the original defaults. form.reset() with new values is needed.
Both isDirty and reset are destructured so the component can track dirty state and reset the form baseline after a successful save.
A useEffect watches for saveStatus === "succeeded", then calls reset() with the new settings values. This clears isDirty and shows a sonner toast. resetSaveStatus() prevents the effect from re-triggering.
disabled={!isDirty || isSaving} ensures the button is only clickable when the user has actually changed something.