Use useEffectEvent for effects with deliberately narrow deps

`pnpm lint` runs oxlint with `--quiet`, which hides warnings, so 14
react-hooks/exhaustive-deps warnings were sitting unseen. Five are the shape
useEffectEvent exists for: an effect that must read the latest value of
something without re-running when it changes.

- CustomFeedEmptyState, FindContactsSettings: fire-and-forget metrics that
  omitted `ax` from the dep array
- AutosizedTextarea: the `onUpdateHeight` callback prop
- SuggestedLanguage: replaces a `useNonReactiveObject` mirror, which was doing
  the same job by hand
- VideoEmbedInnerWeb: the hls subtitle handler called a captured
  `updateCuePositions`, so it would have gone stale if that identity changed

Warnings: 14 -> 9. The rest are useMemo/useCallback/useImperativeHandle, where
useEffectEvent does not apply.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Tomek Zawadzki
2026-08-25 17:38:51 +02:00
parent f87fdd2ea2
commit ed6754301a
5 changed files with 48 additions and 19 deletions
@@ -1,4 +1,11 @@
import {useCallback, useEffect, useId, useRef, useState} from 'react'
import {
useCallback,
useEffect,
useEffectEvent,
useId,
useRef,
useState,
} from 'react'
import {View} from 'react-native'
import {useLingui} from '@lingui/react/macro'
import type * as HlsTypes from 'hls.js'
@@ -264,6 +271,14 @@ function useHLS({
},
)
/*
* The hls handler below must call the latest `updateCuePositions` without the
* effect tearing down and re-attaching every time its identity changes.
*/
const onSubtitleFragProcessed = useEffectEvent(() => {
updateCuePositions()
})
useEffect(() => {
if (!videoRef.current) return
if (!Hls) return
@@ -302,7 +317,7 @@ function useHLS({
})
hls.on(Hls.Events.SUBTITLE_FRAG_PROCESSED, () => {
updateCuePositions()
onSubtitleFragProcessed()
})
hls.on(Hls.Events.FRAG_BUFFERED, (_event, {frag}) => {
+6 -2
View File
@@ -1,4 +1,4 @@
import {useEffect, useMemo, useRef, useState} from 'react'
import {useEffect, useEffectEvent, useMemo, useRef, useState} from 'react'
import {
TextInput,
type TextInputContentSizeChangeEvent,
@@ -141,13 +141,17 @@ export function AutosizedTextarea({
* Reset native height state after a programmatic clear. Android uses it as
* the explicit input height, while iOS uses it to decide when to scroll.
*/
const reportHeight = useEffectEvent((height: number) => {
onUpdateHeight?.(height)
})
const prevRawValue = useRef(rawValue || '')
useEffect(() => {
if (!IS_NATIVE) return
if (rawValue === undefined) return // uncontrolled
if (prevRawValue.current?.length && rawValue === '') {
setNativeHeight(minInputHeight)
onUpdateHeight?.(minInputHeight)
reportHeight(minInputHeight)
}
prevRawValue.current = rawValue
}, [rawValue, minInputHeight])
+10 -5
View File
@@ -1,4 +1,4 @@
import {useCallback, useEffect, useState} from 'react'
import {useCallback, useEffect, useEffectEvent, useState} from 'react'
import {type ListRenderItemInfo, View} from 'react-native'
import * as Contacts from 'expo-contacts'
import {type DidString} from '@atproto/syntax'
@@ -61,12 +61,17 @@ export function FindContactsSettingsScreen({}: Props) {
const {data, error, refetch} = useContactsSyncStatusQuery()
const isFocused = useIsFocused()
const logPresented = useEffectEvent(() => {
ax.metric('contacts:settings:presented', {
hasPreviouslySynced: !!data?.syncStatus,
matchCount: data?.syncStatus?.matchesCount,
})
})
useEffect(() => {
if (data && isFocused) {
ax.metric('contacts:settings:presented', {
hasPreviouslySynced: !!data.syncStatus,
matchCount: data.syncStatus?.matchesCount,
})
logPresented()
}
}, [data, isFocused])
@@ -1,4 +1,4 @@
import {useEffect, useMemo, useRef, useState} from 'react'
import {useEffect, useEffectEvent, useMemo, useRef, useState} from 'react'
import {Platform, Text as RNText, View} from 'react-native'
import {parseLanguageString} from '@atproto/syntax'
import {
@@ -332,15 +332,18 @@ function GuessedLanguage({
onDeclineOuter()
}
const metaRef = useNonReactiveObject(metadata)
useEffect(() => {
const logSuggestion = useEffectEvent(() => {
ax.metric('composer:language:suggestLanguage', {
os: Platform.OS,
suggestedLanguage: language,
currentTargetLanguages: metaRef.current.currentTargetLanguages,
currentTargetLanguages: metadata.currentTargetLanguages,
textLength: sanitizeTextForDetection(metadata.rawText).length,
})
}, [ax, language])
})
useEffect(() => {
logSuggestion()
}, [language])
return (
<LanguageSuggestionButton
+7 -5
View File
@@ -1,4 +1,4 @@
import {useCallback, useEffect, useRef} from 'react'
import {useCallback, useEffect, useEffectEvent, useRef} from 'react'
import {StyleSheet, View} from 'react-native'
import {Trans, useLingui} from '@lingui/react/macro'
import {useNavigation} from '@react-navigation/native'
@@ -23,6 +23,11 @@ export function CustomFeedEmptyState() {
const {currentAccount} = useSession()
const hasLoggedDiscoverEmptyErrorRef = useRef(false)
const logDiscoverEmptyError = useEffectEvent((did: string) => {
hasLoggedDiscoverEmptyErrorRef.current = true
ax.metric('feed:discover:emptyError', {userDid: did})
})
useEffect(() => {
// Log the empty feed error event
if (feedFeedback.feedSourceInfo && currentAccount?.did) {
@@ -31,10 +36,7 @@ export function CustomFeedEmptyState() {
uri === DISCOVER_FEED_URI &&
!hasLoggedDiscoverEmptyErrorRef.current
) {
hasLoggedDiscoverEmptyErrorRef.current = true
ax.metric('feed:discover:emptyError', {
userDid: currentAccount.did,
})
logDiscoverEmptyError(currentAccount.did)
}
}
}, [feedFeedback.feedSourceInfo, currentAccount?.did])