Do these components use refs as state? When we update the component ref, do we update the DOM directly? When we update the component's ref, does the component render and show the value or the change on the screen? If I create an internal state (useState) in the component, but do not update that state, is the component still controlled?
This is an example of an uncontrolled TextInput. In this example I am using a core component (which has its native implementation and is not controlled by default). So I use a Ref to access the text that is inserted into the Input:
//TextInput Uncontrolled
import React from 'react';
import {SafeAreaView, StyleSheet, TextInput, Button, Alert} from 'react-native';
const styles = StyleSheet.create({
input: {
height: 50,
margin: 12,
marginTop: 300,
borderWidth: 2,
padding: 10,
},
});
export default function TextInputUncontrolled () {
const textInputRef = React.useRef(null);
let value = "";
const handleSubmit = () => {
value = textInputRef.current?.value;
console.log (value);
Alert.alert('Value:', value);
};
return (
<SafeAreaView>
<TextInput
ref = {textInputRef}
style = {styles.input}
onChangeText = {(e) => (textInputRef.current.value = e)}
/>
<Button
title="Submit"
onPress={handleSubmit}
/>
</SafeAreaView>
);
}
I don't know, for example, how TextInput manages its state internally. And how it updates the screen with the text that is entered without using useState and passes this value with Ref. If I want, for example, to create a component with a similar behavior, I don't know how I would do it. And if I, for example, create a custom component encapsulating the TextInput and create a useState that will only update when I want to use the controlled version of the component and when I want to use the uncontrolled version, I would not pass this state as a parameter.
RN uses native platform's own state management mechanisms for uncontrolled components. This is how it works
Bridge Communication:
React Native uses JavaScript bridge to communicate with native UI components Uncontrolled components maintains its own state so JS side keeps a reference to this component, but doesn't directly control its entire state
Platform Specific State:
On iOS (UIKit): Uses native responder chain and view state management
On Android (Android View system): Uses View state and property tracking
In short, the answer to your question is no. React Native doesn’t use
useReffor uncontrolled elements, and its state management is more complex than we think. It uses React tags, and each component gets a unique
reactTag that acts as an internal identifier across the JS and native layers.
This behavior might have changed, and you can read more about it here: New Suspense SSR Architecture in React 18 and Document Controlled vs Uncontrolled Component Design pattern
As far as I know, I haven’t found anything interesting yet that, as a consumer, we don't need to worry about. Just knowing how to use it is enough. However, if you really want to deep dive, you can explore its source code. Unfortunately, there aren’t enough details about this topic in the articles or videos I’ve come across.
useControlledState
import { useCallback, useEffect, useRef, useState } from 'react';
export function useControlledState<T, C = T>(
value: Exclude<T, undefined>,
defaultValue: Exclude<T, undefined> | undefined,
onChange?: (v: C, ...args: any[]) => void
): [T, (value: T, ...args: any[]) => void];
export function useControlledState<T, C = T>(
value: Exclude<T, undefined> | undefined,
defaultValue: Exclude<T, undefined>,
onChange?: (v: C, ...args: any[]) => void
): [T, (value: T, ...args: any[]) => void];
export function useControlledState<T, C = T>(
value: T,
defaultValue: T,
onChange?: (v: C, ...args: any[]) => void
): [T, (value: T, ...args: any[]) => void] {
let [stateValue, setStateValue] = useState(value || defaultValue);
let isControlledRef = useRef(value !== undefined);
let isControlled = value !== undefined;
useEffect(() => {
let wasControlled = isControlledRef.current;
if (wasControlled !== isControlled) {
// eslint-disable-next-line no-console
console.warn(
`WARN: A component changed from ${wasControlled ? 'controlled' : 'uncontrolled'} to ${isControlled ? 'controlled' : 'uncontrolled'}.`
);
}
isControlledRef.current = isControlled;
}, [isControlled]);
let currentValue = isControlled ? value : stateValue;
let setValue = useCallback(
(value: any, ...args: any[]) => {
let onChangeCaller = (value: any, ...onChangeArgs: any[]) => {
if (onChange) {
if (!Object.is(currentValue, value)) {
onChange(value, ...onChangeArgs);
}
}
if (!isControlled) {
// eslint-disable-next-line react-hooks/exhaustive-deps
currentValue = value;
}
};
if (typeof value === 'function') {
// eslint-disable-next-line no-console
console.warn(
'We can not support a function callback. See Github Issues for details https://github.com/adobe/react-spectrum/issues/2320'
);
let updateFunction = (oldValue: any, ...functionArgs: any[]) => {
let interceptedValue = value(
isControlled ? currentValue : oldValue,
...functionArgs
);
onChangeCaller(interceptedValue, ...args);
if (!isControlled) {
return interceptedValue;
}
return oldValue;
};
setStateValue(updateFunction);
} else {
if (!isControlled) {
setStateValue(value);
}
onChangeCaller(value, ...args);
}
},
[isControlled, currentValue, onChange]
);
return [currentValue, setValue];
}
useHeader
import { useRouter, usePathname } from 'next/navigation';
import { LINKS } from '@/config';
import { Roles } from '@/enums';
import useSwitchProfile from '@/hooks/remote/useSwitchProfile';
import useMe from '@/hooks/useMe';
import { TextColorProps } from '@/types';
import { useControlledState } from './useControlledState';
export type HeaderVersions = 'full' | 'basic' | 'none';
export type HeaderThemes = 'light' | 'dark' | 'yellow' | 'transparent-white';
export default function useHeader({
version,
theme,
isMenuOpenProp,
onMenuOpenChangeProp,
}: {
version: HeaderVersions;
theme: HeaderThemes;
isMenuOpenProp?: boolean;
// eslint-disable-next-line no-unused-vars
onMenuOpenChangeProp?: (isOpen: boolean) => void;
}) {
const userData = useMe();
const { isSwitching, switchProfile } = useSwitchProfile();
const router = useRouter();
const [isMenuOpen, setIsMenuOpen] = useControlledState(
isMenuOpenProp,
false, // default value if not controlled
onMenuOpenChangeProp
);
const isHidden = version === 'none';
const isLight = theme === 'light';
const isYellow = theme === 'yellow';
const isBlack = theme === 'dark';
const isTransparentWhite = theme === 'transparent-white';
const textColorBasedOnTheme =
isLight || isYellow ? 'charcoal' : ('white' as TextColorProps);
const pathname = usePathname();
const dashboardUrl =
userData?.role === Roles.ADMIN
? LINKS.DASHBOARD_OVERVIEW
: userData?.role === Roles.LANDLORD ||
userData?.role === Roles.SUB_USER ||
userData?.role === Roles.AGENT
? LINKS.DASHBOARD_PROPERTIES_STATS
: userData?.role === Roles.TENANT
? LINKS.TENANT_DASHBOARD_APPLICATIONS
: LINKS.LOGIN;
const handleDashboardClick = () => {
router.push(dashboardUrl);
};
const handleSettingClick = () => {
router.push(LINKS.PROFILE);
};
const isUserOnProfilePage = pathname === LINKS.PROFILE;
const shouldHideSwitchProfileButton =
(userData?.role !== Roles.LANDLORD && userData?.role !== Roles.TENANT) ||
!isUserOnProfilePage;
const switchRoleMessage =
userData?.role === Roles.LANDLORD
? 'Switch to Tenant'
: 'Switch to Landlord';
return {
onDashboardClick: handleDashboardClick,
dashboardUrl,
isBlack,
isYellow,
isLight,
isTransparentWhite,
textColorBasedOnTheme,
isHidden,
isMenuOpen,
setIsMenuOpen,
onSettingClick: handleSettingClick,
shouldHideSwitchProfileButton,
switchRoleMessage,
onSwitchProfile: () => {
switchProfile();
},
isProfileSwitching: isSwitching,
};
}
I have added a code example from a real-world project. You can use the same approach to create any kind of custom component. Now, I can use the useHeader as controlled or uncontrolled