Skip to content
All posts

React NativeExpoTutorial

React Native Text Input: Props, Textarea and Forms

Robin Faraj

To create a text input in React Native, import TextInput from react-native, keep its text in state, and pass that state back through value while updating it in onChangeText. That gives you a controlled text field. Props such as keyboardType, autoComplete, secureTextEntry and multiline turn the same component into an email field, a password field or a textarea.

This guide covers the props you will actually use, a textarea that behaves the same on iOS and Android, keeping the keyboard from covering your fields, and a full form validated with React Hook Form and Zod. The code targets React Native 0.86 and Expo SDK 57. At the end we show the same field built with Uniwind classes and with HeroUI Native's TextField.

A controlled TextInput

TextInput is one of React Native's core components, so there is nothing to install. The minimal version:

import { useState } from "react";
import { StyleSheet, Text, TextInput, View } from "react-native";

export function NameField() {
  const [name, setName] = useState("");

  return (
    <View style={styles.field}>
      <Text style={styles.label}>Full name</Text>
      <TextInput
        value={name}
        onChangeText={setName}
        placeholder="Ada Lovelace"
        accessibilityLabel="Full name"
        style={styles.input}
      />
    </View>
  );
}

const styles = StyleSheet.create({
  field: { gap: 6 },
  label: { fontSize: 14, fontWeight: "600" },
  input: {
    minHeight: 48,
    paddingHorizontal: 14,
    borderWidth: 1,
    borderColor: "#d4d4d8",
    borderRadius: 10,
    fontSize: 16,
  },
});

onChangeText receives the new string. There is also onChange, which receives a native event, but you rarely need it. If you come from React on the web, this replaces <input value onChange> and event.target.value; our React vs React Native post lists the other swaps.

Two habits worth having from the first field. Give the input an accessibilityLabel (a visible Text above it is not linked to it for screen readers the way an HTML <label> is). And use minHeight instead of height, so text still fits when the user has a large font size set.

React Native TextInput props you will use

The TextInput reference lists around 70 props and methods, plus everything View accepts. These are the ones that show up in almost every app:

PropWhat it doesTypical values
keyboardTypeWhich keyboard opens"email-address", "number-pad", "phone-pad", "decimal-pad", "url"
autoCompleteAutofill hint, both platforms"email", "current-password", "new-password", "one-time-code", "name", "tel"
textContentTypeiOS-only autofill hint"emailAddress", "password", "oneTimeCode"
secureTextEntryHides the texttrue for passwords
autoCapitalizeCapitalization"none" for emails and usernames
autoCorrectSpelling correctionfalse for emails, codes, usernames
returnKeyTypeLabel on the return key"next", "done", "go", "search", "send"
submitBehaviorWhat return does"submit", "blurAndSubmit", "newline"
maxLengthHard character limita number
multilineSeveral lines (a textarea)true
editable / readOnlyLock the fieldfalse / true

On autofill: the docs say to avoid setting both textContentType and autoComplete, because textContentType wins on iOS when both are set. autoComplete covers both platforms, so use it alone unless you need an iOS-only value.

Here is an email and password pair that autofills from the password manager, moves to the password field when you press "next", and submits on "go":

import { useRef, useState } from "react";
import { TextInput, View } from "react-native";

export function SignInFields({ onSubmit }: { onSubmit: (email: string, password: string) => void }) {
  const [email, setEmail] = useState("");
  const [password, setPassword] = useState("");
  const passwordRef = useRef<TextInput>(null);

  return (
    <View style={{ gap: 12 }}>
      <TextInput
        value={email}
        onChangeText={setEmail}
        placeholder="you@example.com"
        accessibilityLabel="Email"
        keyboardType="email-address"
        autoComplete="email"
        autoCapitalize="none"
        autoCorrect={false}
        returnKeyType="next"
        submitBehavior="submit"
        onSubmitEditing={() => passwordRef.current?.focus()}
      />
      <TextInput
        ref={passwordRef}
        value={password}
        onChangeText={setPassword}
        placeholder="Password"
        accessibilityLabel="Password"
        secureTextEntry
        autoComplete="current-password"
        returnKeyType="go"
        onSubmitEditing={() => onSubmit(email, password)}
      />
    </View>
  );
}

submitBehavior="submit" on the email field fires onSubmitEditing without blurring, so the keyboard stays up while focus moves to the password. You will still see blurOnSubmit in older tutorials. It is deprecated.

React Native's TextInput reference marking blurOnSubmit as deprecated in favor of submitBehavior

submitBehavior replaced blurOnSubmit and overrides it when both are set. Source

For a sign-up form, switch the password to autoComplete="new-password" so iOS and Android offer to generate and save a strong one. For SMS codes, autoComplete="one-time-code" with keyboardType="number-pad" lets the system fill the code from the message.

Text area in React Native: multiline input

There is no separate <textarea>. A textarea in React Native is a TextInput with multiline. The part that catches everyone is vertical alignment.

React Native docs note that multiline aligns text to the top on iOS and centers it on Android unless textAlignVertical is set to top

Without textAlignVertical: "top", an Android textarea starts typing in the middle of the box. Source

A textarea with a character counter:

import { useState } from "react";
import { StyleSheet, Text, TextInput, View } from "react-native";

const MAX = 500;

export function FeedbackField() {
  const [text, setText] = useState("");

  return (
    <View style={{ gap: 6 }}>
      <TextInput
        value={text}
        onChangeText={setText}
        placeholder="What should we improve?"
        accessibilityLabel="Feedback"
        multiline
        numberOfLines={5}
        maxLength={MAX}
        textAlignVertical="top"
        style={styles.textarea}
      />
      <Text style={styles.counter} accessibilityLiveRegion="polite">
        {text.length} / {MAX}
      </Text>
    </View>
  );
}

const styles = StyleSheet.create({
  textarea: {
    minHeight: 120,
    maxHeight: 240,
    padding: 14,
    borderWidth: 1,
    borderColor: "#d4d4d8",
    borderRadius: 10,
    fontSize: 16,
  },
  counter: { alignSelf: "flex-end", fontSize: 12, color: "#71717a" },
});

minHeight sets the starting size, and maxHeight stops the box from growing past a point, after which it scrolls. numberOfLines sets the maximum number of lines; on iOS it only works on the New Architecture, which is the only architecture in React Native 0.86 anyway. In a multiline field, return inserts a newline by default. If you want return to send (a chat composer, for example), set submitBehavior="submit" and handle onSubmitEditing.

Keeping the keyboard from covering your fields

The keyboard does not move your layout out of the way by itself. React Native gives you KeyboardAvoidingView, and Expo's keyboard handling guide suggests padding on iOS and no behavior on Android:

import { KeyboardAvoidingView, Platform } from "react-native";

<KeyboardAvoidingView
  behavior={Platform.OS === "ios" ? "padding" : undefined}
  style={{ flex: 1 }}
>
  {/* your fields */}
</KeyboardAvoidingView>

That is fine for a screen with one or two fields near the top. For real forms, the same guide recommends react-native-keyboard-controller, which scrolls the focused input into view and behaves the same on both platforms.

Expo's keyboard handling guide calling KeyboardAvoidingView good for prototyping and recommending KeyboardAwareScrollView from Keyboard Controller

Expo's guide: KeyboardAvoidingView for prototypes, KeyboardAwareScrollView for forms. Source

npx expo install react-native-keyboard-controller

Wrap the app once, in your root layout:

// app/_layout.tsx
import { Stack } from "expo-router";
import { KeyboardProvider } from "react-native-keyboard-controller";

export default function RootLayout() {
  return (
    <KeyboardProvider>
      <Stack />
    </KeyboardProvider>
  );
}

Then use KeyboardAwareScrollView where you would use a ScrollView:

import { KeyboardAwareScrollView } from "react-native-keyboard-controller";

<KeyboardAwareScrollView
  bottomOffset={24}
  keyboardShouldPersistTaps="handled"
  contentContainerStyle={{ padding: 16, gap: 16 }}
>
  {/* fields and a submit button */}
</KeyboardAwareScrollView>

bottomOffset is the gap kept between the focused field and the keyboard. keyboardShouldPersistTaps="handled" lets a tap on the submit button register while the keyboard is open, instead of only dismissing it. The library needs Reanimated and is not in Expo Go, so you need a development build. If bottom tabs jump above the keyboard on Android, Expo's guide suggests "softwareKeyboardLayoutMode": "pan" under android in your app config, or tabBarHideOnKeyboard: true on the tab navigator.

Forms with validation: React Hook Form and Zod

Managing every field with its own useState gets messy past three fields. React Hook Form tracks values, touched state and errors, and Zod describes the rules once. Versions as of October 2026: react-hook-form 7.89, @hookform/resolvers 5.9 and zod 4.6.

npx expo install react-hook-form @hookform/resolvers zod

React Native has no <input> for React Hook Form's register to attach to, so each field goes through a Controller:

import { zodResolver } from "@hookform/resolvers/zod";
import { Controller, useForm } from "react-hook-form";
import { Pressable, Text, TextInput, View } from "react-native";
import { z } from "zod";

const schema = z.object({
  email: z.email("Enter a valid email address"),
  password: z.string().min(8, "Use at least 8 characters"),
});

type SignUpValues = z.infer<typeof schema>;

export function SignUpForm({ onSignUp }: { onSignUp: (values: SignUpValues) => Promise<void> }) {
  const {
    control,
    handleSubmit,
    formState: { errors, isSubmitting },
  } = useForm({
    resolver: zodResolver(schema),
    defaultValues: { email: "", password: "" },
  });

  return (
    <View style={{ gap: 16 }}>
      <Controller
        control={control}
        name="email"
        render={({ field: { onChange, onBlur, value, ref } }) => (
          <TextInput
            ref={ref}
            value={value}
            onChangeText={onChange}
            onBlur={onBlur}
            placeholder="you@example.com"
            accessibilityLabel="Email"
            keyboardType="email-address"
            autoComplete="email"
            autoCapitalize="none"
            autoCorrect={false}
          />
        )}
      />
      {errors.email ? (
        <Text role="alert" accessibilityLiveRegion="polite" style={{ color: "#dc2626" }}>
          {errors.email.message}
        </Text>
      ) : null}

      <Controller
        control={control}
        name="password"
        render={({ field: { onChange, onBlur, value, ref } }) => (
          <TextInput
            ref={ref}
            value={value}
            onChangeText={onChange}
            onBlur={onBlur}
            placeholder="Password"
            accessibilityLabel="Password"
            secureTextEntry
            autoComplete="new-password"
          />
        )}
      />
      {errors.password ? (
        <Text role="alert" accessibilityLiveRegion="polite" style={{ color: "#dc2626" }}>
          {errors.password.message}
        </Text>
      ) : null}

      <Pressable
        onPress={handleSubmit(onSignUp)}
        disabled={isSubmitting}
        accessibilityRole="button"
        accessibilityState={{ disabled: isSubmitting }}
      >
        <Text>{isSubmitting ? "Creating account" : "Create account"}</Text>
      </Pressable>
    </View>
  );
}

What each piece does:

  • zodResolver(schema) runs the Zod schema on submit and maps its messages onto errors. Leaving the generic off useForm lets it infer the types from the resolver.
  • z.email() is the Zod 4 form; z.string().email() still works but is deprecated.
  • Passing ref to the TextInput lets React Hook Form focus the first invalid field when submit fails.
  • handleSubmit(onSignUp) returns a function, which is why it goes straight into onPress. It only calls onSignUp when validation passes, and isSubmitting stays true while your promise runs.
  • role="alert" and accessibilityLiveRegion make TalkBack and VoiceOver announce the error when it appears.

By default React Hook Form validates on submit, then re-validates a field as it changes. Pass mode: "onBlur" to useForm if you want errors as soon as the user leaves a field.

Styling: StyleSheet or Tailwind classes with Uniwind

Everything above uses StyleSheet, which ships with React Native and needs no setup. If you prefer Tailwind, Uniwind brings Tailwind CSS v4 to React Native and adds className to core components. Style props become classes, and color props that are not styles (placeholder, cursor, selection) get their own ...ClassName props with an accent- prefix.

Uniwind's TextInput docs listing className, cursorColorClassName and selectionColorClassName bindings

Uniwind maps className to style, and color props to accent- classes. Source

import { TextInput } from "react-native";

<TextInput
  value={email}
  onChangeText={setEmail}
  placeholder="you@example.com"
  accessibilityLabel="Email"
  className="min-h-12 rounded-xl border border-zinc-300 bg-white px-4 text-base text-zinc-900 dark:border-zinc-700 dark:bg-zinc-900 dark:text-white"
  placeholderTextColorClassName="accent-zinc-400 dark:accent-zinc-500"
  selectionColorClassName="accent-blue-500"
/>

The trade-off is the usual one. StyleSheet has no build step and no surprises. Classes keep the style next to the markup and pick up dark mode and your theme tokens, which matters once you have twenty forms instead of two. NativeExpress uses Uniwind, so light and dark themes generated from one seed color reach every input without a hand-edited stylesheet.

HeroUI Native's TextField

A component library saves you the label, helper text and error wiring. HeroUI Native splits a field into parts: TextField holds the state, and Label, Input, Description and FieldError read it.

HeroUI Native TextField anatomy: TextField wrapping Label, Input, Description and FieldError

TextField passes isInvalid, isRequired and isDisabled down to its parts. Source

The email field from the form above, rebuilt with it:

import { Controller } from "react-hook-form";
import { FieldError, Input, Label, TextField } from "heroui-native";

<Controller
  control={control}
  name="email"
  render={({ field: { onChange, onBlur, value }, fieldState: { error } }) => (
    <TextField isRequired isInvalid={!!error}>
      <Label>Email</Label>
      <Input
        value={value}
        onChangeText={onChange}
        onBlur={onBlur}
        placeholder="you@example.com"
        keyboardType="email-address"
        autoComplete="email"
        autoCapitalize="none"
      />
      <FieldError>{error?.message}</FieldError>
    </TextField>
  )}
/>

Input accepts every TextInput prop, so everything earlier in this post still applies. isRequired adds the asterisk to the label, and isInvalid switches the border and shows FieldError. For a textarea, swap Input for TextArea, which is the same component with multiline on and text aligned to the top, which solves the Android centering problem for you. Inside HeroUI's BottomSheet, inputs handle the keyboard themselves through the isBottomSheetAware prop, which defaults to true.

Here is what that looks like in a finished app, from the NativeExpress demo:

Sign-in screen with Google, Apple and email options

Sign-in with Google, Apple and email

Edit profile screen

Editing a profile

Email and password fields on the sign-in screen, and a name field in the profile editor.

If your form also needs a select, the React Native dropdown guide covers HeroUI's Select, the native picker and a custom accessible version. And if you want these screens without building them, the React Native starter kit page lists what ships.

FAQ

How do I create a text input in React Native?

Import TextInput from react-native, store the text with useState, and pass it as value while updating it with onChangeText. Add placeholder, an accessibilityLabel and a style. Nothing needs to be installed, because TextInput is a core component.

How do I make a textarea in React Native?

Set multiline on a TextInput, give it a minHeight, and add textAlignVertical="top" so Android starts the text at the top like iOS does. Use maxLength for a character limit and maxHeight to stop it from growing indefinitely.

How do I get the value of a TextInput in React Native?

Keep it in state through onChangeText, which gives you the current string on every change. For forms with several fields, React Hook Form's Controller holds the values, and handleSubmit passes them all to your submit function at once.

What is TextInput in React Native?

TextInput is the core component for typing text with the on-screen keyboard. It renders a native UITextField or UITextView on iOS and an EditText on Android, and its props control the keyboard type, autofill, secure entry and what the return key does.