diff --git a/CLAUDE.md b/CLAUDE.md
index d90cbf7cb3..7212eb6165 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -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
-
-
-// Avoid when possible - controlled (can cause performance issues)
-
-```
-
### Platform-Specific Behavior
Some components behave differently across platforms:
diff --git a/src/components/ProgressGuide/FollowDialog.tsx b/src/components/ProgressGuide/FollowDialog.tsx
index 5e6a3a97ca..29649d8ab8 100644
--- a/src/components/ProgressGuide/FollowDialog.tsx
+++ b/src/components/ProgressGuide/FollowDialog.tsx
@@ -139,7 +139,6 @@ function DialogInner({guide}: {guide?: Follow10ProgressGuide}) {
const [searchText, setSearchText] = useState(lastSearchText)
const moderationOpts = useModerationOpts()
const listRef = useRef(null)
- const inputRef = useRef>(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 = (
| null>
listRef: React.RefObject
onSelectTab: (v: string) => void
searchText: string
@@ -396,8 +391,7 @@ let Header = ({
{
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 | 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}
/>
{
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('')
}}>
{[...value.padEnd(numberOfDigits, ' ')].map((digit, index) => {
diff --git a/src/components/dialogs/EmailDialog/screens/Update.tsx b/src/components/dialogs/EmailDialog/screens/Update.tsx
index 0261e48c75..d537f7919c 100644
--- a/src/components/dialogs/EmailDialog/screens/Update.tsx
+++ b/src/components/dialogs/EmailDialog/screens/Update.tsx
@@ -218,12 +218,9 @@ export function Update(_props: ScreenProps) {
& {
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
diff --git a/src/features/gifPicker/GifPickerDialog.tsx b/src/features/gifPicker/GifPickerDialog.tsx
index 02cb8ced3e..14e1a08e13 100644
--- a/src/features/gifPicker/GifPickerDialog.tsx
+++ b/src/features/gifPicker/GifPickerDialog.tsx
@@ -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({
<>
0}
onEscape={() => control.close()}
/>
{showPills && (
diff --git a/src/features/gifPicker/components/GifPickerHeader.tsx b/src/features/gifPicker/components/GifPickerHeader.tsx
index 62dfea5eb9..c6ab1ba725 100644
--- a/src/features/gifPicker/components/GifPickerHeader.tsx
+++ b/src/features/gifPicker/components/GifPickerHeader.tsx
@@ -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>
+ 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 && (
@@ -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')
diff --git a/src/screens/Messages/ConversationSettings/index.tsx b/src/screens/Messages/ConversationSettings/index.tsx
index 2ddea16b4c..86b3972ead 100644
--- a/src/screens/Messages/ConversationSettings/index.tsx
+++ b/src/screens/Messages/ConversationSettings/index.tsx
@@ -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({
editGroupName({name: newGroupName})}
/>
diff --git a/src/screens/Messages/ConversationSettings/prompts.tsx b/src/screens/Messages/ConversationSettings/prompts.tsx
index 84c1a1bea7..23d7c4f333 100644
--- a/src/screens/Messages/ConversationSettings/prompts.tsx
+++ b/src/screens/Messages/ConversationSettings/prompts.tsx
@@ -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({
void
onSubmitEditing?: () => void
}) {
const t = useTheme()
const {t: l} = useLingui()
- const inputRef = useRef>(null)
- const [showClear, setShowClear] = useState(defaultValue.length > 0)
return (
{
- setShowClear(text.length > 0)
- onChangeText(text)
- }}
+ onChangeText={onChangeText}
onSubmitEditing={onSubmitEditing}
/>
- {showClear && (
+ {value.length > 0 && (