AniUI Academy

TextInput and Forms

TextInput's controlled pattern, keyboard-related props, and multi-field form state -- then the plain TypeScript validation logic that behaves the same on any platform.

9 min read

A form is where a React Native app first has to take real, messy input from a person instead of just displaying data. TextInput is the component that does it, and — like most of this framework — it looks close enough to a web <input> to be dangerous if you assume it's identical.

The controlled pattern

TextInput is controlled the same way a web input can be: a value prop tells it what to display, and a callback tells you when the user changed it. The difference is in the callback's shape.

import { useState } from "react";
import { TextInput } from "react-native";
 
function EmailField() {
  const [email, setEmail] = useState("");
 
  return (
    <TextInput
      value={email}
      onChangeText={setEmail}
      placeholder="you@example.com"
    />
  );
}

Notice it's onChangeText, not onChange. On the web, <input onChange={e => setEmail(e.target.value)}> hands you a synthetic event, and you dig the string out of e.target.value yourself. React Native skips that: onChangeText calls your handler with the new string directly, which is why setEmail can be passed straight in as the handler above with no wrapper function. There is an onChange on TextInput too, but it carries a native event object (mainly useful for things like tracking selection) — for "what did the user type," onChangeText is what you want almost every time.

A handful of props steer the on-screen keyboard and how it treats what's typed, and getting them right is most of what separates a form that feels native from one that feels like it was built for a mouse:

<TextInput
  value={email}
  onChangeText={setEmail}
  keyboardType="email-address"
  autoCapitalize="none"
  autoCorrect={false}
/>
 
<TextInput
  value={password}
  onChangeText={setPassword}
  secureTextEntry
  autoCapitalize="none"
/>
  • keyboardType swaps the on-screen keyboard layout — "email-address" surfaces @ prominently, "numeric" or "phone-pad" show a number pad instead of the full alphabet, and so on.
  • secureTextEntry masks each typed character, the standard behavior for a password field.
  • autoCapitalize controls whether RN auto-capitalizes as the user types. Its default is meant for writing prose, which is exactly wrong for an email address or username — "none" turns it off.

None of these change what value ends up in state. They only change how the keyboard behaves while the user is producing that value, which is worth keeping straight: a bug in one of these props makes typing feel wrong, while a bug in your onChangeText wiring makes the data wrong.

Multi-field form state

One useState per field works fine for a couple of fields:

function SignupForm() {
  const [email, setEmail] = useState("");
  const [password, setPassword] = useState("");
 
  return (
    <>
      <TextInput value={email} onChangeText={setEmail} keyboardType="email-address" autoCapitalize="none" />
      <TextInput value={password} onChangeText={setPassword} secureTextEntry autoCapitalize="none" />
    </>
  );
}

As the field count grows, a single object is usually cleaner than a pile of parallel useState calls, updating one key at a time:

function SignupForm() {
  const [values, setValues] = useState({ email: "", password: "" });
 
  const setField = (key: keyof typeof values) => (text: string) =>
    setValues((prev) => ({ ...prev, [key]: text }));
 
  return (
    <>
      <TextInput value={values.email} onChangeText={setField("email")} keyboardType="email-address" autoCapitalize="none" />
      <TextInput value={values.password} onChangeText={setField("password")} secureTextEntry autoCapitalize="none" />
    </>
  );
}

Either shape works. What matters is that by the time you're ready to validate the form, you have a plain object of strings — { email, password } — with no remaining trace of which component or platform produced them.

Validation is just TypeScript

This is the useful realization: once you have that plain object, checking it for problems has nothing to do with React Native at all. It's the same kind of function you'd write to validate a web form, a CLI's input, or a test fixture.

function validateSignupForm(values: {
  email: string;
  password: string;
}): Record<string, string> {
  const errors: Record<string, string> = {};
 
  if (!values.email.includes("@")) {
    errors.email = "Enter a valid email address.";
  }
 
  if (values.password.length < 8) {
    errors.password = "Password must be at least 8 characters.";
  }
 
  return errors;
}

It takes strings in, returns a map of field-name to error message out (an empty object means "no errors"). Nothing about the check for @ or the length check cares whether values.email was typed into a TextInput on a phone or an <input> in a browser — it's the same string either way by the time it reaches this function.

Try it yourself
Loading playground...

The validation logic is plain TypeScript and behaves identically whether the values came from a React Native TextInput, a web <input>, or a hardcoded object in a test file. This is worth internalizing as a habit: keep the part that's genuinely platform-specific (rendering the fields) as thin as possible, and push the part that isn't (deciding whether the values are valid) into ordinary functions you could unit test without a device or simulator running at all.

What to remember

  • TextInput is controlled via value and onChangeText — the callback receives the string directly, unlike the web's event-based onChange.
  • keyboardType, secureTextEntry, and autoCapitalize shape how the keyboard behaves, not what ends up in your state.
  • For more than a couple of fields, a single values object usually beats one useState per field.
  • Validation logic that only deals with plain values (strings, numbers) is ordinary TypeScript — it doesn't belong to React Native and doesn't need a TextInput to be tested.
  • Keep the platform-specific part (rendering fields) thin, and push decisions (is this valid?) into plain functions.

Check yourself

4 questions · pass 3/4 to unlock FlatList and Virtualization

up to 50
  1. 1.How does TextInput's controlled pattern differ from the web's <input> element?

  2. 2.Which prop hides typed characters, as you'd want for a password field?

  3. 3.Why write signup form validation as a plain function that only takes { email, password } values, rather than something wired directly into a specific TextInput?

  4. 4.What does autoCapitalize="none" do?

4 left to answer