Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,24 @@ Naming:
- Utilities: camelCase (`format.ts`)
- Types: T-prefixed (`TSortDirection`, `TVaultType`)

### useEffect — prefer alternatives

Avoid `useEffect` when a better primitive exists. Most `useEffect` usage hides derived state, duplicates event handling, or re-implements what TanStack Query already provides.

**Prefer these instead:**
- **Derived state** — compute inline or with `useMemo` instead of `useEffect(() => setX(f(y)), [y])`
- **Event handlers** — do work directly in `onClick`/`onChange` instead of setting a flag for an effect to pick up
- **TanStack Query** — use `useQuery`/`useMutation` for data fetching, never `useEffect` + `fetch` + `setState`
- **`key` prop for reset** — use `<Component key={id} />` to remount instead of `useEffect` that resets state when an ID changes
- **Conditional rendering** — render children only when preconditions are met (e.g., `{!isLoading && <Player />}`) instead of guarding inside an effect

**When `useEffect` is acceptable:**
- One-time DOM/browser API setup on mount (IntersectionObserver, event listeners, focus)
- Third-party library lifecycle (init/destroy)
- Cases where no declarative alternative exists

When writing a new `useEffect`, add a brief comment explaining why an alternative does not apply.

## Architecture

**Tech stack:** React 19, Vite, React Router (lazy-loaded), Tailwind CSS 4, TanStack Query, Wagmi/Viem/RainbowKit
Expand Down
16 changes: 9 additions & 7 deletions src/components/Image.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,13 +42,22 @@ function Image(props: CustomImageProps): ReactElement {
} = props

const [imageSrc, setImageSrc] = useState<string | typeof src>(src)
const [prevSrc, setPrevSrc] = useState(src)
const [isVisible, setIsVisible] = useState(loading !== 'lazy' || priority === true)
const [isLoading, setIsLoading] = useState(true)
const [hasError, setHasError] = useState(false)
const imageRef = useRef<HTMLDivElement>(null)
const imgRef = useRef<HTMLImageElement>(null)
const observerRef = useRef<IntersectionObserver | null>(null)

// Render-time state adjustment: reset when src prop changes
if (src !== prevSrc) {
setPrevSrc(src)
setImageSrc(src)
setHasError(false)
setIsLoading(true)
}

// Set up IntersectionObserver for lazy loading
useEffect(() => {
if (loading !== 'lazy' || priority || !imageRef.current) return
Expand All @@ -75,13 +84,6 @@ function Image(props: CustomImageProps): ReactElement {
}
}, [loading, priority])

// Reset states when src changes
useEffect(() => {
setImageSrc(src)
setHasError(false)
setIsLoading(true)
}, [src])

// Handle already-cached images where onLoad might not fire
useEffect(() => {
if (!isVisible) return
Expand Down
25 changes: 11 additions & 14 deletions src/components/pages/vaults/[chainID]/[address].tsx
Original file line number Diff line number Diff line change
Expand Up @@ -569,15 +569,13 @@ function Index(): ReactElement | null {
} | null>(null)
const tourSectionsRef = useRef<Record<SectionKey, boolean> | null>(null)

useEffect(() => {
setWidgetMode((previous) => (widgetActions.includes(previous) ? previous : widgetActions[0]))
}, [widgetActions])

useEffect(() => {
if (!widgetActions.includes(mobileDrawerAction)) {
setMobileDrawerAction(widgetActions[0])
}
}, [mobileDrawerAction, widgetActions])
// Render-time state adjustment: keep mode valid when available actions change
if (!widgetActions.includes(widgetMode)) {
setWidgetMode(widgetActions[0])
}
if (!widgetActions.includes(mobileDrawerAction)) {
setMobileDrawerAction(widgetActions[0])
}

useEffect(() => {
if (typeof window === 'undefined') return
Expand Down Expand Up @@ -839,11 +837,10 @@ function Index(): ReactElement | null {
enabled: renderableSections.length > 0 && !isProgrammaticScroll
})

useEffect(() => {
if (!renderableSections.some((section) => section.key === activeSection) && renderableSections[0]) {
setActiveSection(renderableSections[0].key)
}
}, [renderableSections, activeSection])
// Render-time state adjustment: ensure active section is valid
if (!renderableSections.some((section) => section.key === activeSection) && renderableSections[0]) {
setActiveSection(renderableSections[0].key)
}

useEffect(() => {
if (!pendingSectionKey || !isHeaderCompressed) return
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { getVaultKey } from '@shared/hooks/useVaultFilterUtils'
import { IconChevron } from '@shared/icons/IconChevron'
import { cl } from '@shared/utils'
import { motion, type PanInfo, useAnimation } from 'framer-motion'
import { type ReactElement, useCallback, useEffect, useState } from 'react'
import { type ReactElement, useCallback, useState } from 'react'

type TSwipeableCompareCarouselProps = {
vaults: TKongVaultInput[]
Expand All @@ -20,11 +20,10 @@ export function SwipeableCompareCarousel({ vaults, onRemove }: TSwipeableCompare

const maxIndex = vaults.length - 1

useEffect(() => {
if (currentIndex > maxIndex) {
setCurrentIndex(Math.max(0, maxIndex))
}
}, [currentIndex, maxIndex])
// Render-time state adjustment: clamp index when vaults are removed
if (currentIndex > maxIndex) {
setCurrentIndex(Math.max(0, maxIndex))
}

const goToIndex = useCallback(
(index: number) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import type { TKongVaultInput } from '@pages/vaults/domain/kongVaultSelectors'
import { Button } from '@shared/components/Button'
import { EmptyState } from '@shared/components/EmptyState'
import { cl } from '@shared/utils'
import { type ReactElement, useCallback, useEffect, useMemo, useState } from 'react'
import { type ReactElement, useCallback, useMemo, useState } from 'react'

type TVaultsBlockingFilterAction = {
key: string
Expand Down Expand Up @@ -46,9 +46,10 @@ export function VaultsListEmpty({
)
const selectedBlockingFilterKeys = useMemo(() => new Set(selectedBlockingFilters), [selectedBlockingFilters])

useEffect(() => {
// Render-time state adjustment: prune selections when available options change
if (selectedBlockingFilters.some((key) => !availableBlockingFilterKeys.has(key))) {
setSelectedBlockingFilters((prev) => prev.filter((key) => availableBlockingFilterKeys.has(key)))
}, [availableBlockingFilterKeys])
}

const toggleBlockingFilter = useCallback((key: string): void => {
setSelectedBlockingFilters((prev) => (prev.includes(key) ? prev.filter((entry) => entry !== key) : [...prev, key]))
Expand Down
13 changes: 4 additions & 9 deletions src/components/pages/vaults/components/widget/TokenSelector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { TokenLogo } from '@shared/components/TokenLogo'
import { useWallet } from '@shared/contexts/useWallet'
import type { TToken } from '@shared/types'
import { cl, formatTAmount, toAddress } from '@shared/utils'
import { type FC, useCallback, useEffect, useMemo, useState } from 'react'
import { type FC, useCallback, useMemo, useState } from 'react'
import { isAddress } from 'viem'
import { CloseIcon } from './shared/Icons'

Expand Down Expand Up @@ -93,10 +93,12 @@ export const TokenSelector: FC<TokenSelectorProps> = ({
stakingAddress
}) => {
const [searchText, setSearchText] = useState('')
const [customAddress, setCustomAddress] = useState<`0x${string}` | undefined>()
const [selectedChainId, setSelectedChainId] = useState(chainId)
const { getToken, isLoading, balances } = useWallet()

// Derived: treat valid address input as custom token
const customAddress = searchText && isAddress(searchText) ? (searchText as `0x${string}`) : undefined

// Available chains - you can expand this list as needed
const availableChains = useMemo(
() => [
Expand Down Expand Up @@ -191,13 +193,6 @@ export const TokenSelector: FC<TokenSelectorProps> = ({
})
}, [tokens, limitTokens, excludeTokens, searchText])

// Check if search text is a valid address
useEffect(() => {
if (searchText && isAddress(searchText) && searchText !== customAddress) {
setCustomAddress(searchText as `0x${string}`)
}
}, [searchText, customAddress])

const handleSelect = useCallback(
(address: `0x${string}`) => {
onChange(address, selectedChainId)
Expand Down
34 changes: 24 additions & 10 deletions src/components/pages/vaults/components/widget/deposit/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,6 @@ export function WidgetDeposit({
const [showTokenSelector, setShowTokenSelector] = useState(false)
const [showTransactionOverlay, setShowTransactionOverlay] = useState(false)
const [isDetailsPanelOpen, setIsDetailsPanelOpen] = useState(false)
const [hasAcceptedPriceImpact, setHasAcceptedPriceImpact] = useState(false)
const appliedPrefillRef = useRef<string | null>(null)

const {
Expand Down Expand Up @@ -231,11 +230,10 @@ export function WidgetDeposit({
setShowTokenSelector
})

useEffect(() => {
if (!shouldCollapseDetails && isDetailsPanelOpen) {
setIsDetailsPanelOpen(false)
}
}, [isDetailsPanelOpen, shouldCollapseDetails])
// Render-time state adjustment: close panel when collapse is disabled
if (!shouldCollapseDetails && isDetailsPanelOpen) {
setIsDetailsPanelOpen(false)
}

const { routeType, activeFlow } = useDepositFlow({
depositToken,
Expand Down Expand Up @@ -370,9 +368,20 @@ export function WidgetDeposit({
activeFlow.periphery.expectedOut
])

useEffect(() => {
setHasAcceptedPriceImpact(false)
}, [priceImpactAcceptanceKey])
const [priceImpactAcceptanceState, setPriceImpactAcceptanceState] = useState<{
key: string
isAccepted: boolean
}>({
key: priceImpactAcceptanceKey,
isAccepted: false
})
if (priceImpactAcceptanceState.key !== priceImpactAcceptanceKey) {
setPriceImpactAcceptanceState({
key: priceImpactAcceptanceKey,
isAccepted: false
})
}
const hasAcceptedPriceImpact = priceImpactAcceptanceState.isAccepted

const formattedDepositAmount = formatTAmount({ value: depositAmount.bn, decimals: inputToken?.decimals ?? 18 })
const needsApproval = !isNativeToken && !activeFlow.periphery.isAllowanceSufficient
Expand Down Expand Up @@ -573,7 +582,12 @@ export function WidgetDeposit({
<input
type="checkbox"
checked={hasAcceptedPriceImpact}
onChange={(e) => setHasAcceptedPriceImpact(e.target.checked)}
onChange={(e) =>
setPriceImpactAcceptanceState({
key: priceImpactAcceptanceKey,
isAccepted: e.target.checked
})
}
className="size-4 rounded border-red-500/50 bg-transparent text-red-500 focus:ring-red-500/50"
/>
<span className="text-sm text-red-500">I understand and wish to continue</span>
Expand Down
19 changes: 5 additions & 14 deletions src/components/pages/vaults/components/widget/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,15 +12,7 @@ import type { VaultUserData } from '@pages/vaults/hooks/useVaultUserData'
import { WidgetActionType as ActionType } from '@pages/vaults/types'
import type { TAddress } from '@shared/types'
import { cl, isZeroAddress, toAddress } from '@shared/utils'
import {
type ForwardedRef,
forwardRef,
type ReactElement,
type ReactNode,
useEffect,
useImperativeHandle,
useState
} from 'react'
import { type ForwardedRef, forwardRef, type ReactElement, type ReactNode, useImperativeHandle, useState } from 'react'
import { WidgetDeposit } from './deposit'
import { WidgetMigrate } from './migrate'
import { WidgetWithdraw } from './withdraw'
Expand Down Expand Up @@ -108,11 +100,10 @@ export const Widget = forwardRef<TWidgetRef, Props>(function Widget(
}
}))

useEffect(() => {
if (mode === undefined) {
setInternalMode(actions[0])
}
}, [actions, mode])
// Render-time state adjustment: keep internal mode valid when actions change
if (mode === undefined && !actions.includes(internalMode)) {
setInternalMode(actions[0])
}

function renderSelectedComponent(): ReactElement {
switch (currentMode) {
Expand Down
52 changes: 31 additions & 21 deletions src/components/pages/vaults/components/widget/withdraw/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,6 @@ export function WidgetWithdraw({
const [showTransactionOverlay, setShowTransactionOverlay] = useState(false)
const [withdrawalSource, setWithdrawalSource] = useState<WithdrawalSource>(stakingAddress ? null : 'vault')
const [isDetailsPanelOpen, setIsDetailsPanelOpen] = useState(false)
const [hasAcceptedPriceImpact, setHasAcceptedPriceImpact] = useState(false)
const appliedPrefillRef = useRef<string | null>(null)
const [fallbackStep, setFallbackStep] = useState<'unstake' | 'withdraw'>('unstake')
const [redeemSharesOverride, setRedeemSharesOverride] = useState<bigint>(0n)
Expand Down Expand Up @@ -187,17 +186,13 @@ export function WidgetWithdraw({
const hasBothBalances = hasVaultBalance && hasStakingBalance
const singleSource = resolveWithdrawalSource(hasVaultBalance, hasStakingBalance)

useEffect(() => {
if (singleSource) {
setWithdrawalSource(singleSource)
}
}, [singleSource])

useEffect(() => {
if (!collapseDetails && isDetailsPanelOpen) {
setIsDetailsPanelOpen(false)
}
}, [collapseDetails, isDetailsPanelOpen])
// Render-time state adjustments
if (singleSource && withdrawalSource !== singleSource) {
setWithdrawalSource(singleSource)
}
if (!collapseDetails && isDetailsPanelOpen) {
setIsDetailsPanelOpen(false)
}

useResetEnsoSelection({
ensoEnabled,
Expand Down Expand Up @@ -350,11 +345,10 @@ export function WidgetWithdraw({
: directWithdrawFlow.actions.prepareWithdraw
const effectiveWithdrawAmountRaw = expectedOutOverride ?? withdrawAmount.bn

useEffect(() => {
if (optimisticApprovedShares !== null && activeFlow.periphery.allowance >= optimisticApprovedShares) {
setOptimisticApprovedShares(null)
}
}, [activeFlow.periphery.allowance, optimisticApprovedShares])
// Render-time adjustment: clear optimistic approval when actual allowance catches up
if (optimisticApprovedShares !== null && activeFlow.periphery.allowance >= optimisticApprovedShares) {
setOptimisticApprovedShares(null)
}

useEffect(() => {
if (optimisticApprovedShares === null) return
Expand Down Expand Up @@ -497,9 +491,20 @@ export function WidgetWithdraw({
effectiveExpectedOut
])

useEffect(() => {
setHasAcceptedPriceImpact(false)
}, [priceImpactAcceptanceKey])
const [priceImpactAcceptanceState, setPriceImpactAcceptanceState] = useState<{
key: string
isAccepted: boolean
}>({
key: priceImpactAcceptanceKey,
isAccepted: false
})
if (priceImpactAcceptanceState.key !== priceImpactAcceptanceKey) {
setPriceImpactAcceptanceState({
key: priceImpactAcceptanceKey,
isAccepted: false
})
}
const hasAcceptedPriceImpact = priceImpactAcceptanceState.isAccepted

const canOpenTokenSelector = ensoEnabled && !disableTokenSelector
const shouldShowZapUi = !isBaseWithdrawToken
Expand Down Expand Up @@ -760,7 +765,12 @@ export function WidgetWithdraw({
<input
type="checkbox"
checked={hasAcceptedPriceImpact}
onChange={(e) => setHasAcceptedPriceImpact(e.target.checked)}
onChange={(e) =>
setPriceImpactAcceptanceState({
key: priceImpactAcceptanceKey,
isAccepted: e.target.checked
})
}
className="size-4 rounded border-red-500/50 bg-transparent text-red-500 focus:ring-red-500/50"
/>
<span className="text-sm text-red-500">I understand and wish to continue</span>
Expand Down
Loading
Loading