Drop the controlled-input warning and adopt controlled inputs

`TextField.Input`'s `value` prop carried a `@deprecated` tag steering
everyone toward `defaultValue`. That was an old-architecture performance
concern; we're on the New Architecture now, so the warning is gone and
CLAUDE.md says controlled inputs are the default choice.

Audited the uncontrolled inputs that warning produced. The interesting
ones were carrying workarounds to paper over the fact that the input and
the state describing it could drift apart:

- Advanced search: `ClearableInput` kept its own `showClear` state and
  cleared itself through a ref, and `FilterBlock` remounted it (and the
  already-controlled `AutocompleteInput`) with `key={filter.field}` to
  reseed it. All three are gone.
- Group chat: `EditNamePrompt` took an `inputKey` that
  `ConversationSettings` bumped on every open to remount the input,
  because the native bottom sheet keeps children mounted across opens.
- Follow dialog and GIF picker: both cleared their search field through
  a ref alongside the state update. The GIF picker's field had no
  `value` at all, so its clear button was driven by a separate prop.
- Login and signup: both mirrored every field into a ref so submit could
  read it, keeping two copies of the same string. Signup's
  `prevEmailValueRef` stays - it tracks the last email we warned about,
  not the input.
- The email dialog froze its field by dropping `onChangeText` after a
  successful update, which stopped recording edits but didn't stop them
  being typed. Now it's `editable={false}`.

The rest were plain `defaultValue` + `setState` pairs where the state
was already the source of truth for validation, character counters and
dirty checks: profile and list editing, both alt text dialogs, appeal
reason, handle change, app password name.

Also fixed `OTPInput`, which was controlled but cleared itself through
`clear()` on tap - the digit row renders from `value`, so the code
appeared to survive a clear that had already emptied the input.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016imsvP5dtEKBr6hCy1X62K
This commit is contained in:
Claude
2026-09-02 22:15:43 +00:00
parent af3fbcc940
commit b111676744
21 changed files with 72 additions and 142 deletions
+5 -20
View File
@@ -288,8 +288,11 @@ existing usages across the app for a canonical example.
### TextField
Compound component at `#/components/forms/TextField` (`TextField.LabelText`,
`TextField.Root`, `TextField.Icon`, `TextField.Input`). Prefer `defaultValue` over
`value` (see Footguns).
`TextField.Root`, `TextField.Icon`, `TextField.Input`). Controlled inputs
(`value` + `onChangeText`) are fine and are usually what you want - the old
advice to reach for `defaultValue` was a New Architecture migration concern and
no longer applies. Reach for `defaultValue` only when nothing outside the input
needs to read the text.
### Typography
@@ -507,24 +510,6 @@ This applies to:
The Menu component on iOS specifically uses this pattern  see `src/components/Menu/index.tsx:151`.
### Controlled vs Uncontrolled Inputs
Prefer `defaultValue` over `value` for TextInput on the old architecture:
```tsx
// Preferred - uncontrolled
<TextField.Input
defaultValue={initialEmail}
onChangeText={setEmail}
/>
// Avoid when possible - controlled (can cause performance issues)
<TextField.Input
value={email}
onChangeText={setEmail}
/>
```
### Platform-Specific Behavior
Some components behave differently across platforms:
+4 -13
View File
@@ -139,7 +139,6 @@ function DialogInner({guide}: {guide?: Follow10ProgressGuide}) {
const [searchText, setSearchText] = useState(lastSearchText)
const moderationOpts = useModerationOpts()
const listRef = useRef<ListMethods>(null)
const inputRef = useRef<React.ComponentRef<typeof TextInput>>(null)
const [headerHeight, setHeaderHeight] = useState(0)
const {currentAccount} = useSession()
@@ -305,7 +304,6 @@ function DialogInner({guide}: {guide?: Follow10ProgressGuide}) {
const onSelectTab = useCallback(
(interest: string) => {
setSelectedInterest(interest)
inputRef.current?.clear()
setSearchText('')
listRef.current?.scrollToOffset({
offset: 0,
@@ -318,7 +316,6 @@ function DialogInner({guide}: {guide?: Follow10ProgressGuide}) {
const listHeader = (
<Header
guide={guide}
inputRef={inputRef}
listRef={listRef}
searchText={searchText}
onSelectTab={onSelectTab}
@@ -357,7 +354,6 @@ function DialogInner({guide}: {guide?: Follow10ProgressGuide}) {
let Header = ({
guide,
inputRef,
listRef,
searchText,
onSelectTab,
@@ -368,7 +364,6 @@ let Header = ({
interestsDisplayNames,
}: {
guide?: Follow10ProgressGuide
inputRef: React.RefObject<React.ComponentRef<typeof TextInput> | null>
listRef: React.RefObject<ListMethods | null>
onSelectTab: (v: string) => void
searchText: string
@@ -396,8 +391,7 @@ let Header = ({
<View style={[web(a.pt_xs), a.pb_xs]}>
<SearchInput
inputRef={inputRef}
defaultValue={searchText}
value={searchText}
onChangeText={text => {
setSearchText(text)
listRef.current?.scrollToOffset({offset: 0, animated: false})
@@ -668,13 +662,11 @@ function CardOuter({
function SearchInput({
onChangeText,
onEscape,
inputRef,
defaultValue,
value,
}: {
onChangeText: (text: string) => void
onEscape: () => void
inputRef: React.RefObject<React.ComponentRef<typeof TextInput> | null>
defaultValue: string
value: string
}) {
const t = useTheme()
const {t: l} = useLingui()
@@ -698,9 +690,8 @@ function SearchInput({
fill={interacted ? t.palette.primary_500 : t.palette.contrast_300}
/>
<TextInput
ref={inputRef}
placeholder={l`Search by name or interest`}
defaultValue={defaultValue}
value={value}
onChangeText={onChangeText}
onFocus={onFocus}
onBlur={onBlur}
@@ -58,7 +58,12 @@ export function OTPInput({
style={[a.w_full, a.relative]}
onPress={() => {
innerRef.current?.focus()
innerRef.current?.clear()
/*
* Clear through state, not the input's imperative `clear()`: the digit
* row above renders from `value`, so a native-only clear would leave
* stale digits on screen.
*/
onChange('')
}}>
<View style={[a.w_full, a.flex_row, a.gap_sm]}>
{[...value.padEnd(numberOfDigits, ' ')].map((digit, index) => {
@@ -218,12 +218,9 @@ export function Update(_props: ScreenProps<ScreenID.Update>) {
<TextField.Input
label={l`New email address`}
placeholder={l`alice@example.com`}
defaultValue={state.email}
onChangeText={
state.mutationStatus === 'success'
? undefined
: handleEmailChange
}
value={state.email}
editable={state.mutationStatus !== 'success'}
onChangeText={handleEmailChange}
keyboardType="email-address"
autoComplete="email"
autoCapitalize="none"
@@ -421,7 +421,7 @@ function DialogInner({
</TextField.LabelText>
<TextField.Root isInvalid={displayNameTooLong || displayNameTooShort}>
<Dialog.Input
defaultValue={displayName}
value={displayName}
onChangeText={onChangeDisplayName}
label={_(msg`Name`)}
placeholder={displayNamePlaceholder}
@@ -457,7 +457,7 @@ function DialogInner({
</TextField.LabelText>
<TextField.Root isInvalid={descriptionTooLong}>
<Dialog.Input
defaultValue={descriptionRt.text}
value={descriptionRt.text}
onChangeText={onChangeDescription}
multiline
label={_(msg`Description`)}
-7
View File
@@ -156,13 +156,6 @@ export type InputProps = Omit<
'value' | 'onChangeText' | 'placeholder'
> & {
label: string
/**
* @deprecated Controlled inputs are *strongly* discouraged. Use `defaultValue` instead where possible.
*
* See https://github.com/facebook/react-native-website/pull/4247
*
* Note: This guidance no longer applies once we migrate to the New Architecture!
*/
value?: string
onChangeText?: (value: string) => void
isInvalid?: boolean
+1 -2
View File
@@ -115,7 +115,6 @@ function GifPickerBody({
}, [effectiveSearch, isRecentsActive])
const onClearSearch = () => {
textInputRef.current?.clear()
setRawSearch('')
setActiveCategory('trending')
textInputRef.current?.focus()
@@ -148,9 +147,9 @@ function GifPickerBody({
<>
<GifPickerHeader
inputRef={textInputRef}
value={rawSearch}
onChangeText={onChangeSearch}
onClear={onClearSearch}
canClear={rawSearch.length > 0}
onEscape={() => control.close()}
/>
{showPills && (
@@ -10,16 +10,16 @@ import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
export function GifPickerHeader({
inputRef,
value,
onChangeText,
onClear,
onEscape,
canClear,
}: {
inputRef: Ref<React.ComponentRef<typeof TextInput>>
value: string
onChangeText: (text: string) => void
onClear: () => void
onEscape: () => void
canClear: boolean
}) {
const {t: l} = useLingui()
const t = useTheme()
@@ -47,6 +47,7 @@ export function GifPickerHeader({
comment:
'Placeholder text inside the GIF search input. KLIPY is the third-party GIF provider; keep the brand name as-is.',
})}
value={value}
onChangeText={onChangeText}
returnKeyType="search"
inputRef={inputRef}
@@ -57,7 +58,7 @@ export function GifPickerHeader({
}
}}
/>
{canClear && (
{value.length > 0 && (
<Button
size="tiny"
color="secondary"
+15 -19
View File
@@ -73,15 +73,13 @@ export const LoginForm = ({
>('none')
const [isAuthFactorTokenNeeded, setIsAuthFactorTokenNeeded] = useState(false)
const [showResolveError, setShowResolveError] = useState(false)
const identifierValueRef = useRef(initialHandle || '')
const passwordValueRef = useRef('')
const [identifier, setIdentifier] = useState(initialHandle || '')
const [password, setPassword] = useState('')
const [identifierFocused, setIdentifierFocused] = useState(false)
const [authFactorToken, setAuthFactorToken] = useState('')
const identifierRef = useRef<React.ComponentRef<typeof TextInput>>(null)
const passwordRef = useRef<React.ComponentRef<typeof TextInput>>(null)
const hasFocusedOnce = useRef(false)
const [hasPassword, setHasPassword] = useState(false)
const [revealPassword, setRevealPassword] = useState(false)
const {t: l} = useLingui()
const {login} = useSessionApi()
@@ -119,7 +117,6 @@ export const LoginForm = ({
* transitions away) and clears it on any failure.
*/
const attemptLogin = async (service: string, fullIdent: string) => {
const password = passwordValueRef.current
setIsProcessing(true)
try {
@@ -184,10 +181,9 @@ export const LoginForm = ({
setErrorField('none')
setShowResolveError(false)
const identifier = identifierValueRef.current.toLowerCase().trim()
const password = passwordValueRef.current
const normalizedIdentifier = identifier.toLowerCase().trim()
if (!identifier) {
if (!normalizedIdentifier) {
setError(l`Please enter your username`)
setErrorField('identifier')
return
@@ -202,11 +198,11 @@ export const LoginForm = ({
setIsProcessing(true)
// try to guess the handle if the user just gave their own username
let fullIdent = identifier
let fullIdent = normalizedIdentifier
if (
!identifier.includes('@') && // not an email
!identifier.includes('.') && // not a domain
!identifier.startsWith('did:') && // not a DID
!normalizedIdentifier.includes('@') && // not an email
!normalizedIdentifier.includes('.') && // not a domain
!normalizedIdentifier.startsWith('did:') && // not a DID
serviceDescription &&
serviceDescription.availableUserDomains.length > 0
) {
@@ -218,7 +214,7 @@ export const LoginForm = ({
}
if (!matched) {
fullIdent = createFullHandle(
identifier,
normalizedIdentifier,
serviceDescription.availableUserDomains[0],
)
}
@@ -235,7 +231,8 @@ export const LoginForm = ({
let service: string
let did: string | null
try {
;({service, did} = await hostingProvider.resolveService(identifier))
;({service, did} =
await hostingProvider.resolveService(normalizedIdentifier))
} catch (err) {
logger.debug('Failed to resolve hosting provider', {error: String(err)})
setIsProcessing(false)
@@ -321,9 +318,8 @@ export const LoginForm = ({
autoComplete="username"
returnKeyType="next"
textContentType="username"
defaultValue={initialHandle || ''}
value={identifier}
onChangeText={v => {
identifierValueRef.current = v
setIdentifier(v)
if (errorField) setErrorField('none')
if (showResolveError) setShowResolveError(false)
@@ -379,10 +375,10 @@ export const LoginForm = ({
returnKeyType="done"
enablesReturnKeyAutomatically={true}
secureTextEntry={!revealPassword}
value={password}
onChangeText={v => {
passwordValueRef.current = v
setPassword(v)
if (errorField) setErrorField('none')
setHasPassword(!!v)
}}
onSubmitEditing={() => void onPressNext()}
blurOnSubmit={false} // HACK: https://github.com/facebook/react-native/issues/21911#issuecomment-558343069 Keyboard blur behavior is now handled in onSubmitEditing
@@ -412,7 +408,7 @@ export const LoginForm = ({
/>
<RevealPasswordButton
active={revealPassword}
hasPassword={hasPassword}
hasPassword={!!password}
onPress={() => setRevealPassword(r => !r)}
/>
</TextField.Root>
@@ -447,7 +443,7 @@ export const LoginForm = ({
autoComplete="one-time-code"
returnKeyType="done"
blurOnSubmit={false} // prevents flickering due to onSubmitEditing going to next field
value={authFactorToken} // controlled input due to uncontrolled input not receiving pasted values properly
value={authFactorToken}
onChangeText={text => {
setAuthFactorToken(text)
if (errorField) setErrorField('none')
@@ -339,11 +339,9 @@ function SettingsHeader({
const groupName = convo.details.name
const [newGroupName, setNewGroupName] = useState(groupName)
const [editNameInputKey, setEditNameInputKey] = useState(0)
const openEditNamePrompt = () => {
setNewGroupName(groupName)
setEditNameInputKey(k => k + 1)
editNamePrompt.open()
}
@@ -591,7 +589,6 @@ function SettingsHeader({
<EditNamePrompt
control={editNamePrompt}
value={newGroupName}
inputKey={editNameInputKey}
onChangeText={setNewGroupName}
onConfirm={() => editGroupName({name: newGroupName})}
/>
@@ -12,18 +12,11 @@ import {Text} from '#/components/Typography'
export function EditNamePrompt({
control,
value,
inputKey,
onChangeText,
onConfirm,
}: {
control: Dialog.DialogOuterProps['control']
value: string
/**
* Bump this whenever the prompt is opened to remount the (uncontrolled)
* input and reseed it from `value`. Required because the native bottom sheet
* keeps its children mounted across opens.
*/
inputKey: number
onChangeText: (value: string) => void
onConfirm: () => void
}) {
@@ -45,10 +38,9 @@ export function EditNamePrompt({
<View style={[a.my_sm]}>
<TextField.Root isInvalid={nameTooLong}>
<TextField.Input
key={inputKey}
label={l`Edit group name`}
placeholder={l`Group name`}
defaultValue={value}
value={value}
onChangeText={onChangeText}
returnKeyType="done"
autoCapitalize="none"
@@ -311,7 +311,7 @@ function DialogInner({
</TextField.LabelText>
<TextField.Root isInvalid={displayNameTooLong}>
<Dialog.Input
defaultValue={displayName}
value={displayName}
onChangeText={setDisplayName}
label={_(msg`Display name`)}
placeholder={_(msg`e.g. Alice Lastname`)}
@@ -361,7 +361,7 @@ function DialogInner({
</TextField.LabelText>
<TextField.Root isInvalid={descriptionTooLong}>
<Dialog.Input
defaultValue={description}
value={description}
onChangeText={setDescription}
multiline
label={_(msg`Description`)}
@@ -1,5 +1,4 @@
import {useRef, useState} from 'react'
import {type TextInput, View} from 'react-native'
import {View} from 'react-native'
import {useLingui} from '@lingui/react/macro'
import {atoms as a, useTheme} from '#/alf'
@@ -9,59 +8,47 @@ import * as TextField from '#/components/forms/TextField'
import {TimesLarge_Stroke2_Corner0_Rounded as XIcon} from '#/components/icons/Times'
/**
* A text input with a clear (X) button inside it on the right. The input stays
* uncontrolled (defaultValue + imperative clear) per the codebase's preference;
* local state only drives whether the clear button is shown.
* A text input with a clear (X) button inside it on the right.
*/
export function ClearableInput({
label,
defaultValue,
value,
placeholder,
onChangeText,
onSubmitEditing,
}: {
label: string
defaultValue: string
value: string
placeholder?: string
onChangeText: (text: string) => void
onSubmitEditing?: () => void
}) {
const t = useTheme()
const {t: l} = useLingui()
const inputRef = useRef<React.ComponentRef<typeof TextInput>>(null)
const [showClear, setShowClear] = useState(defaultValue.length > 0)
return (
<View style={[a.relative]}>
<TextField.Root>
<Dialog.Input
inputRef={inputRef}
label={label}
defaultValue={defaultValue}
value={value}
placeholder={placeholder}
keyboardAppearance={t.scheme}
autoCorrect={false}
autoComplete="off"
autoCapitalize="none"
style={[a.pr_2xl]}
onChangeText={text => {
setShowClear(text.length > 0)
onChangeText(text)
}}
onChangeText={onChangeText}
onSubmitEditing={onSubmitEditing}
/>
</TextField.Root>
{showClear && (
{value.length > 0 && (
<View
style={[a.absolute, a.justify_center, {top: 0, bottom: 0, right: 8}]}>
<Button
label={l`Clear`}
onPress={() => {
inputRef.current?.clear()
setShowClear(false)
onChangeText('')
}}
onPress={() => onChangeText('')}
size="tiny"
color="secondary"
shape="round">
@@ -154,11 +154,6 @@ export function FilterBlock({
{HANDLE_FIELDS.has(filter.field) ? (
<AutocompleteInput
/*
* Remount on field-type change so the input resets to the cleared
* value; mode changes keep the same field and so preserve the text.
*/
key={filter.field}
label={labels[filter.field].label}
value={filter.value}
onChangeText={text => onChange({value: text})}
@@ -166,13 +161,8 @@ export function FilterBlock({
/>
) : (
<ClearableInput
/*
* The input is uncontrolled (defaultValue), so remount on field-type
* change to reset it; mode changes keep the same field and text.
*/
key={filter.field}
label={labels[filter.field].label}
defaultValue={filter.value}
value={filter.value}
onChangeText={text => onChange({value: text})}
onSubmitEditing={onSubmitEditing}
/>
@@ -246,7 +246,7 @@ function DialogInner({
</TextField.LabelText>
<ClearableInput
label={l`Search query`}
defaultValue={query}
value={query}
placeholder={l({
message: 'cats dogs',
comment:
@@ -264,7 +264,7 @@ function DialogInner({
</TextField.LabelText>
<ClearableInput
label={l`None of these words`}
defaultValue={negatedWords}
value={negatedWords}
placeholder={l({
message: 'cows pigs',
comment:
@@ -281,7 +281,7 @@ function DialogInner({
</TextField.LabelText>
<ClearableInput
label={l`This exact phrase`}
defaultValue={exactPhrase}
value={exactPhrase}
placeholder={l({
message: 'whats up',
comment: 'Advanced search: Example of an “exact phrase” search',
@@ -121,6 +121,7 @@ function CreateDialogInner({passwords}: {passwords: string[]}) {
<Dialog.Input
label={_(msg`App Password`)}
placeholder={autogeneratedName}
value={name}
onChangeText={setName}
returnKeyType="done"
onSubmitEditing={() => createAppPassword()}
@@ -231,7 +231,7 @@ function ProvidedHandlePage({
<TextField.Icon icon={AtIcon} />
<Dialog.Input
editable={!isPending}
defaultValue={subdomain}
value={subdomain}
onChangeText={text => setSubdomain(text)}
label={_(msg`New handle`)}
placeholder={_(msg`e.g. alice`)}
@@ -387,7 +387,7 @@ function OwnHandlePage({goToServiceHandle}: {goToServiceHandle: () => void}) {
label={_(msg`New handle`)}
placeholder={_(msg`e.g. alice.com`)}
editable={!isPending}
defaultValue={domain}
value={domain}
onChangeText={text => {
setDomain(text)
resetVerification()
+11 -15
View File
@@ -76,10 +76,11 @@ export function StepInfo({
const preemptivelyCompleteActivePolicyUpdate =
usePreemptivelyCompleteActivePolicyUpdate()
const inviteCodeValueRef = useRef<string>(state.inviteCode)
const emailValueRef = useRef<string>(state.email)
/**
* The last email we ran the "did you really mean this?" check against, so
* the warning is only shown once per distinct address.
*/
const prevEmailValueRef = useRef<string>(state.email)
const passwordValueRef = useRef<string>(state.password)
const emailInputRef = useRef<React.ComponentRef<typeof TextInput>>(null)
const passwordInputRef = useRef<React.ComponentRef<typeof TextInput>>(null)
@@ -111,10 +112,8 @@ export function StepInfo({
}, [])
const onNextPress = () => {
const inviteCode = inviteCodeValueRef.current
const email = emailValueRef.current
const {inviteCode, email, password} = state
const emailChanged = prevEmailValueRef.current !== email
const password = passwordValueRef.current
if (!isOverRegionMinAccessAge) {
return
@@ -170,9 +169,6 @@ export function StepInfo({
}
preemptivelyCompleteActivePolicyUpdate()
dispatch({type: 'setInviteCode', value: inviteCode})
dispatch({type: 'setEmail', value: email})
dispatch({type: 'setPassword', value: password})
dispatch({type: 'next'})
ax.metric('signup:nextPressed', {
activeStep: state.activeStep,
@@ -207,7 +203,7 @@ export function StepInfo({
<TextField.Icon icon={Ticket} />
<TextField.Input
onChangeText={value => {
inviteCodeValueRef.current = value.trim()
dispatch({type: 'setInviteCode', value: value.trim()})
if (
state.errorField === 'invite-code' &&
value.trim().length > 0
@@ -216,7 +212,7 @@ export function StepInfo({
}
}}
label={l`Required for this provider`}
defaultValue={state.inviteCode}
value={state.inviteCode}
autoCapitalize="none"
autoComplete="email"
keyboardType="email-address"
@@ -239,7 +235,7 @@ export function StepInfo({
testID="emailInput"
inputRef={emailInputRef}
onChangeText={value => {
emailValueRef.current = value.trim()
dispatch({type: 'setEmail', value: value.trim()})
if (hasWarnedEmail) {
setHasWarnedEmail(false)
}
@@ -252,7 +248,7 @@ export function StepInfo({
}
}}
label={l`Enter your email address`}
defaultValue={state.email}
value={state.email}
autoCapitalize="none"
autoComplete="email"
keyboardType="email-address"
@@ -274,13 +270,13 @@ export function StepInfo({
testID="passwordInput"
inputRef={passwordInputRef}
onChangeText={value => {
passwordValueRef.current = value
dispatch({type: 'setPassword', value})
if (state.errorField === 'password' && value.length >= 8) {
dispatch({type: 'clearError'})
}
}}
label={l`Choose your password`}
defaultValue={state.password}
value={state.password}
secureTextEntry
autoComplete="new-password"
autoCapitalize="none"
+1 -1
View File
@@ -162,7 +162,7 @@ export function Takendown() {
}>
<TextField.Input
label={_(msg`Reason for appeal`)}
defaultValue={reason}
value={reason}
onChangeText={setReason}
placeholder={_(msg`Why are you appealing?`)}
multiline
+1 -1
View File
@@ -161,7 +161,7 @@ function AltTextInner({
label={_(msg`Alt text`)}
placeholder={vendorAltText}
onChangeText={onChange}
defaultValue={altText}
value={altText}
multiline
autoFocus
onKeyPress={({nativeEvent}) => {
@@ -129,7 +129,7 @@ const ImageAltTextInner = ({
onChangeText={text => {
setAltText(text)
}}
defaultValue={altText}
value={altText}
multiline
autoFocus
/>