Skip to content

Commit e693edd

Browse files
committed
fix(state): model component useState as single source of truth
- edit-knowledge-base-modal: reset fields on closed→open via prevOpenRef render idiom instead of mirroring props into state through useEffect (a prop change while open no longer clobbers in-progress edits) - use-verification: collapse contradictory isLoading/isVerified/isInvalidOtp booleans into a single status enum + errorMessage; consumer derives flags - contact-form / demo-request-modal: derive busy/success from the mutation object; delete duplicated submitSuccess local state - sim-hooks.md: add state-shape rule (no props-into-state, status enum, derive mutation state)
1 parent 1602009 commit e693edd

6 files changed

Lines changed: 64 additions & 47 deletions

File tree

.claude/rules/sim-hooks.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,3 +48,19 @@ export function useFeature({ id, onSelect }: UseFeatureProps) {
4848
4. Wrap returned functions in useCallback
4949
5. Server data goes through React Query (`hooks/queries/`), never `useState` + `fetch`
5050
6. Keep only UI/orchestration state in these hooks
51+
52+
## State shape
53+
54+
Never mirror a prop into state with `useState(prop)` + a syncing `useEffect` — a prop change clobbers in-progress local edits. Use the prop directly, reset via a remount `key`, or — when you must seed local state from a prop only on a transition (e.g. a modal opening) — reset during render with the `prevX` ref idiom:
55+
56+
```typescript
57+
const prevOpenRef = useRef(open)
58+
if (prevOpenRef.current !== open) {
59+
prevOpenRef.current = open
60+
if (open) setName(initialName) // closed → open only
61+
}
62+
```
63+
64+
Model mutually-exclusive flags as ONE `status` enum, not several contradictory booleans. `isLoading`/`isVerified`/`isInvalidOtp` describing one machine collapse to `status: 'idle' | 'verifying' | 'verified' | 'error'` (+ `errorMessage`); derive any boolean a consumer still needs (`status === 'error'`).
65+
66+
Derive busy/success from the mutation object — never duplicate `mutation.isPending`/`mutation.isSuccess` into local `useState`. Read them directly (`mutation.isSuccess`) and reset with `mutation.reset()`.

apps/sim/app/(auth)/verify/use-verification.ts

Lines changed: 28 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,15 @@ import { validateCallbackUrl } from '@/lib/core/security/input-validation'
99

1010
const logger = createLogger('useVerification')
1111

12+
/**
13+
* Mutually-exclusive phases of the email-OTP verification machine.
14+
* - `idle`: awaiting input
15+
* - `verifying`: a verify request is in flight
16+
* - `verified`: code accepted, redirecting
17+
* - `error`: last verify attempt failed (paired with `errorMessage`)
18+
*/
19+
type VerificationStatus = 'idle' | 'verifying' | 'verified' | 'error'
20+
1221
interface UseVerificationParams {
1322
hasEmailService: boolean
1423
isProduction: boolean
@@ -18,9 +27,8 @@ interface UseVerificationParams {
1827
interface UseVerificationReturn {
1928
otp: string
2029
email: string
21-
isLoading: boolean
22-
isVerified: boolean
23-
isInvalidOtp: boolean
30+
status: VerificationStatus
31+
isResending: boolean
2432
errorMessage: string
2533
isOtpComplete: boolean
2634
hasEmailService: boolean
@@ -41,10 +49,9 @@ export function useVerification({
4149
const { refetch: refetchSession } = useSession()
4250
const [otp, setOtp] = useState('')
4351
const [email, setEmail] = useState('')
44-
const [isLoading, setIsLoading] = useState(false)
45-
const [isVerified, setIsVerified] = useState(false)
52+
const [status, setStatus] = useState<VerificationStatus>('idle')
53+
const [isResending, setIsResending] = useState(false)
4654
const [isSendingInitialOtp, setIsSendingInitialOtp] = useState(false)
47-
const [isInvalidOtp, setIsInvalidOtp] = useState(false)
4855
const [errorMessage, setErrorMessage] = useState('')
4956
const [redirectUrl, setRedirectUrl] = useState<string | null>(null)
5057
const [isInviteFlow, setIsInviteFlow] = useState(false)
@@ -96,8 +103,7 @@ export function useVerification({
96103
async function verifyCode() {
97104
if (!isOtpComplete || !email) return
98105

99-
setIsLoading(true)
100-
setIsInvalidOtp(false)
106+
setStatus('verifying')
101107
setErrorMessage('')
102108

103109
try {
@@ -108,7 +114,7 @@ export function useVerification({
108114
})
109115

110116
if (response && !response.error) {
111-
setIsVerified(true)
117+
setStatus('verified')
112118

113119
try {
114120
await refetchSession()
@@ -135,12 +141,9 @@ export function useVerification({
135141
} else {
136142
logger.info('Setting invalid OTP state - API error response')
137143
const message = 'Invalid verification code. Please check and try again.'
138-
setIsInvalidOtp(true)
144+
setStatus('error')
139145
setErrorMessage(message)
140-
logger.info('Error state after API error:', {
141-
isInvalidOtp: true,
142-
errorMessage: message,
143-
})
146+
logger.info('Error state after API error:', { errorMessage: message })
144147
setOtp('')
145148
}
146149
} catch (error: any) {
@@ -155,23 +158,18 @@ export function useVerification({
155158
message = 'Too many failed attempts. Please request a new code.'
156159
}
157160

158-
setIsInvalidOtp(true)
161+
setStatus('error')
159162
setErrorMessage(message)
160-
logger.info('Error state after caught error:', {
161-
isInvalidOtp: true,
162-
errorMessage: message,
163-
})
163+
logger.info('Error state after caught error:', { errorMessage: message })
164164

165165
setOtp('')
166-
} finally {
167-
setIsLoading(false)
168166
}
169167
}
170168

171169
function resendCode() {
172170
if (!email || !hasEmailService || !isEmailVerificationEnabled) return
173171

174-
setIsLoading(true)
172+
setIsResending(true)
175173
setErrorMessage('')
176174

177175
const normalizedEmail = normalizeEmail(email)
@@ -185,32 +183,32 @@ export function useVerification({
185183
setErrorMessage('Failed to resend verification code. Please try again later.')
186184
})
187185
.finally(() => {
188-
setIsLoading(false)
186+
setIsResending(false)
189187
})
190188
}
191189

192190
function handleOtpChange(value: string) {
193-
if (value.length === 6) {
194-
setIsInvalidOtp(false)
191+
if (value.length === 6 && status === 'error') {
192+
setStatus('idle')
195193
setErrorMessage('')
196194
}
197195
setOtp(value)
198196
}
199197

200198
useEffect(() => {
201-
if (otp.length === 6 && email && !isLoading && !isVerified) {
199+
if (otp.length === 6 && email && status !== 'verifying' && status !== 'verified') {
202200
const timeoutId = setTimeout(() => {
203201
verifyCode()
204202
}, 300)
205203

206204
return () => clearTimeout(timeoutId)
207205
}
208-
}, [otp, email, isLoading, isVerified])
206+
}, [otp, email, status])
209207

210208
useEffect(() => {
211209
if (typeof window !== 'undefined') {
212210
if (!isEmailVerificationEnabled) {
213-
setIsVerified(true)
211+
setStatus('verified')
214212

215213
const handleRedirect = async () => {
216214
try {
@@ -234,9 +232,8 @@ export function useVerification({
234232
return {
235233
otp,
236234
email,
237-
isLoading,
238-
isVerified,
239-
isInvalidOtp,
235+
status,
236+
isResending,
240237
errorMessage,
241238
isOtpComplete,
242239
hasEmailService,

apps/sim/app/(auth)/verify/verify-content.tsx

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -25,16 +25,19 @@ function VerificationForm({
2525
const {
2626
otp,
2727
email,
28-
isLoading,
29-
isVerified,
30-
isInvalidOtp,
28+
status,
29+
isResending,
3130
errorMessage,
3231
isOtpComplete,
3332
verifyCode,
3433
resendCode,
3534
handleOtpChange,
3635
} = useVerification({ hasEmailService, isProduction, isEmailVerificationEnabled })
3736

37+
const isVerified = status === 'verified'
38+
const isInvalidOtp = status === 'error'
39+
const isBusy = status === 'verifying' || isResending
40+
3841
const [countdown, setCountdown] = useState(0)
3942
const [isResendDisabled, setIsResendDisabled] = useState(false)
4043

@@ -88,7 +91,7 @@ function VerificationForm({
8891
maxLength={6}
8992
value={otp}
9093
onChange={handleOtpChange}
91-
disabled={isLoading}
94+
disabled={isBusy}
9295
className={cn('gap-2', isInvalidOtp && 'otp-error')}
9396
>
9497
<InputOTPGroup>
@@ -112,10 +115,10 @@ function VerificationForm({
112115

113116
<button
114117
onClick={verifyCode}
115-
disabled={!isOtpComplete || isLoading}
118+
disabled={!isOtpComplete || isBusy}
116119
className={AUTH_SUBMIT_BTN}
117120
>
118-
{isLoading ? (
121+
{isBusy ? (
119122
<span className='flex items-center gap-2'>
120123
<Loader className='size-4' animate />
121124
Verifying…
@@ -138,7 +141,7 @@ function VerificationForm({
138141
<button
139142
className='font-medium text-[var(--landing-text)] underline-offset-4 transition hover:text-white hover:underline'
140143
onClick={handleResend}
141-
disabled={isLoading || isResendDisabled}
144+
disabled={isBusy || isResendDisabled}
142145
>
143146
Resend
144147
</button>

apps/sim/app/(landing)/components/contact/contact-form.tsx

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,6 @@ export function ContactForm() {
6969
captureClientEvent('landing_contact_submitted', { topic: variables.topic })
7070
setForm(INITIAL_FORM_STATE)
7171
setErrors({})
72-
setSubmitSuccess(true)
7372
},
7473
onError: () => {
7574
turnstileRef.current?.reset()
@@ -78,7 +77,6 @@ export function ContactForm() {
7877

7978
const [form, setForm] = useState<ContactFormState>(INITIAL_FORM_STATE)
8079
const [errors, setErrors] = useState<ContactErrors>({})
81-
const [submitSuccess, setSubmitSuccess] = useState(false)
8280
const [isSubmitting, setIsSubmitting] = useState(false)
8381
const [website, setWebsite] = useState('')
8482
const [widgetReady, setWidgetReady] = useState(false)
@@ -141,6 +139,7 @@ export function ContactForm() {
141139
}
142140

143141
const isBusy = contactMutation.isPending || isSubmitting
142+
const submitSuccess = contactMutation.isSuccess
144143

145144
const submitError = contactMutation.isError
146145
? toError(contactMutation.error).message || 'Failed to send message. Please try again.'
@@ -161,7 +160,7 @@ export function ContactForm() {
161160
</p>
162161
<button
163162
type='button'
164-
onClick={() => setSubmitSuccess(false)}
163+
onClick={() => contactMutation.reset()}
165164
className='mt-6 font-season text-[13px] text-[var(--landing-text)] underline underline-offset-2 transition-opacity hover:opacity-80'
166165
>
167166
Send another message

apps/sim/app/(landing)/components/demo-request/demo-request-modal.tsx

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -69,22 +69,21 @@ export function DemoRequestModal({ children, theme = 'dark' }: DemoRequestModalP
6969
const [open, setOpen] = useState(false)
7070
const [form, setForm] = useState<DemoRequestFormState>(INITIAL_FORM_STATE)
7171
const [errors, setErrors] = useState<DemoRequestErrors>({})
72-
const [submitSuccess, setSubmitSuccess] = useState(false)
7372

7473
const demoMutation = useMutation({
7574
mutationFn: submitDemoRequest,
7675
onSuccess: (_data, variables) => {
7776
captureClientEvent('landing_demo_request_submitted', {
7877
company_size: variables.companySize,
7978
})
80-
setSubmitSuccess(true)
8179
},
8280
})
8381

82+
const submitSuccess = demoMutation.isSuccess
83+
8484
function resetForm() {
8585
setForm(INITIAL_FORM_STATE)
8686
setErrors({})
87-
setSubmitSuccess(false)
8887
demoMutation.reset()
8988
}
9089

apps/sim/app/workspace/[workspaceId]/knowledge/components/edit-knowledge-base-modal/edit-knowledge-base-modal.tsx

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
'use client'
22

3-
import { memo, useEffect, useState } from 'react'
3+
import { memo, useRef, useState } from 'react'
44
import { createLogger } from '@sim/logger'
55
import { getErrorMessage } from '@sim/utils/errors'
66
import {
@@ -44,15 +44,18 @@ export const EditKnowledgeBaseModal = memo(function EditKnowledgeBaseModal({
4444
const [isSubmitting, setIsSubmitting] = useState(false)
4545
const [error, setError] = useState<string | null>(null)
4646

47-
useEffect(() => {
47+
// Reset form fields when the modal opens (open transitions false → true).
48+
const prevOpenRef = useRef(open)
49+
if (prevOpenRef.current !== open) {
50+
prevOpenRef.current = open
4851
if (open) {
4952
setName(initialName)
5053
setDescription(initialDescription)
5154
setNameError(null)
5255
setDescriptionError(null)
5356
setError(null)
5457
}
55-
}, [open, initialName, initialDescription])
58+
}
5659

5760
const validate = (): boolean => {
5861
let valid = true

0 commit comments

Comments
 (0)