0Pricing
React Native Academy · Lesson

Building a Simple Login Form

Create an email and password form that validates input length, shows an error message for invalid entries, and calls a mock login function on submit.

Building a Simple Login Form is a free React Native Academy lesson on CoddyKit — lesson 4 of 4. You can read the complete lesson below for free — then practise it hands-on in the browser with a built-in code editor and a 24/7 AI tutor. It is part of the React Native Academy learning path, one of 4 lessons in the course, and your progress syncs across the web and the CoddyKit app.

Login Form Requirements

A login form is one of the first screens in almost every mobile app. A good implementation must: render email and password fields, validate input (non-empty, valid email format, minimum password length), show field-level error messages below each input, display a loading indicator during the authentication request, show a server error if credentials are wrong, and successfully navigate to the main app on success. This lesson applies TextInput, controlled components, validation logic, and basic state management in one realistic feature.

// State needed for the login form:
// - email: string
// - password: string
// - emailError: string
// - passwordError: string
// - isLoading: boolean
// - serverError: string

// Derived:
// - isSubmitDisabled: !email || !password || isLoading

Form State and Structure

Set up the form's state variables with useState. Using separate states for each field makes updating and validating them independently straightforward. Group the error messages alongside the input values so validation can set and clear them independently. The isLoading flag disables the submit button and shows a spinner while the authentication network request is in progress, preventing double submissions. Clear the serverError whenever the user changes any input value to give them a fresh start.

import { useState } from 'react';

function useLoginForm() {
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');
  const [emailError, setEmailError] = useState('');
  const [passwordError, setPasswordError] = useState('');
  const [serverError, setServerError] = useState('');
  const [isLoading, setIsLoading] = useState(false);

  function handleEmailChange(text) {
    setEmail(text);
    if (serverError) setServerError('');
    if (emailError) setEmailError('');
  }

  function handlePasswordChange(text) {
    setPassword(text);
    if (serverError) setServerError('');
    if (passwordError) setPasswordError('');
  }

  return { email, password, emailError, passwordError, serverError, isLoading, handleEmailChange, handlePasswordChange, setEmailError, setPasswordError, setServerError, setIsLoading };
}

Validation Logic

Write a validate() function that checks each field and sets error messages. For email, check that it is non-empty and matches a basic email regex pattern. For password, check that it is at least 8 characters long. The function returns a boolean — true if all fields pass. Only run validation when the user attempts to submit (not on every keystroke, which is annoying). Return early from the submit handler if validation fails, before making any network request. This pattern keeps validation logic separate from the UI and the API call.

function validateForm({ email, password, setEmailError, setPasswordError }) {
  let valid = true;

  // Email validation
  if (!email.trim()) {
    setEmailError('Email is required');
    valid = false;
  } else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
    setEmailError('Enter a valid email address');
    valid = false;
  } else {
    setEmailError('');
  }

  // Password validation
  if (!password) {
    setPasswordError('Password is required');
    valid = false;
  } else if (password.length < 8) {
    setPasswordError('Password must be at least 8 characters');
    valid = false;
  } else {
    setPasswordError('');
  }

  return valid;
}

The Submit Handler

The submit handler orchestrates validation, the API call, and error handling. It first validates the form and returns early if invalid. Then it sets isLoading to true, makes a mock (or real) authentication request, and handles the response. On success, navigate to the home screen. On failure (wrong credentials), display the server error message. Always reset isLoading in a finally block to ensure the spinner disappears even if the request throws an unexpected error.

async function handleSubmit({
  email, password,
  setEmailError, setPasswordError,
  setServerError, setIsLoading,
  onSuccess
}) {
  if (!validateForm({ email, password, setEmailError, setPasswordError })) return;

  setIsLoading(true);
  setServerError('');

  try {
    // Replace with your real auth API call:
    const result = await mockLogin(email, password);
    if (result.success) {
      onSuccess(result.user);
    } else {
      setServerError('Invalid email or password. Please try again.');
    }
  } catch (error) {
    setServerError('Network error. Please check your connection.');
  } finally {
    setIsLoading(false);
  }
}

async function mockLogin(email, password) {
  await new Promise(r => setTimeout(r, 1200)); // simulate network
  if (email === 'test@example.com' && password === 'password123') {
    return { success: true, user: { name: 'Test User' } };
  }
  return { success: false };
}

Styled Input Field with Error

Build a reusable FormField component that wraps a styled TextInput with a label above and an error message below. The border color changes to red when there's an error. The FormField accepts all relevant TextInput props plus label and error strings. Using a wrapper component avoids repeating the label/error/border pattern for every input in your form. The error text is conditionally rendered — no empty space appears when there is no error.

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

export default function FormField({ label, error, style, ...inputProps }) {
  return (
    <View style={styles.container}>
      <Text style={styles.label}>{label}</Text>
      <TextInput
        style={[
          styles.input,
          error ? styles.inputError : styles.inputNormal,
          style,
        ]}
        placeholderTextColor='#aaa'
        {...inputProps}
      />
      {error ? <Text style={styles.error}>{error}</Text> : null}
    </View>
  );
}

const styles = StyleSheet.create({
  container: { gap: 4 },
  label: { fontSize: 14, fontWeight: '600', color: '#555' },
  input: { borderWidth: 1.5, borderRadius: 10, paddingHorizontal: 14, paddingVertical: 13, fontSize: 16, backgroundColor: '#fff' },
  inputNormal: { borderColor: '#ddd' },
  inputError: { borderColor: '#e74c3c', backgroundColor: '#fff8f8' },
  error: { fontSize: 12, color: '#e74c3c' },
});

Assembling the Login Screen UI

Put the form together: a logo or app name at the top, the email and password FormField components, the server error banner, the submit button (showing a spinner when loading), and a 'Sign up' link at the bottom. Wrap everything in KeyboardAvoidingView so the keyboard doesn't cover the input fields. Use ScrollView inside so tapping outside dismisses the keyboard. The design follows a clean, vertically centered card layout that looks natural on any phone screen size.

import { KeyboardAvoidingView, ScrollView, View, Text, TouchableOpacity, ActivityIndicator, Platform, StyleSheet } from 'react-native';
import FormField from './FormField';

export default function LoginScreen({ onSuccess, onNavigateToSignUp }) {
  const { email, password, emailError, passwordError, serverError, isLoading, handleEmailChange, handlePasswordChange, setEmailError, setPasswordError, setServerError, setIsLoading } = useLoginForm();

  function submit() {
    handleSubmit({ email, password, setEmailError, setPasswordError, setServerError, setIsLoading, onSuccess });
  }

  return (
    <KeyboardAvoidingView style={{ flex: 1 }} behavior={Platform.OS === 'ios' ? 'padding' : 'height'}>
      <ScrollView contentContainerStyle={styles.content} keyboardShouldPersistTaps='handled'>
        <Text style={styles.title}>Welcome Back</Text>
        <Text style={styles.subtitle}>Sign in to your account</Text>

        {serverError ? <Text style={styles.serverError}>{serverError}</Text> : null}

        <FormField label='Email' value={email} onChangeText={handleEmailChange} error={emailError} keyboardType='email-address' autoCapitalize='none' autoCorrect={false} returnKeyType='next' />
        <FormField label='Password' value={password} onChangeText={handlePasswordChange} error={passwordError} secureTextEntry autoCapitalize='none' returnKeyType='done' onSubmitEditing={submit} />

        <TouchableOpacity style={[styles.btn, isLoading && styles.btnLoading]} onPress={submit} disabled={isLoading}>
          {isLoading ? <ActivityIndicator color='#fff' /> : <Text style={styles.btnText}>Sign In</Text>}
        </TouchableOpacity>
      </ScrollView>
    </KeyboardAvoidingView>
  );
}

const styles = StyleSheet.create({
  content: { flexGrow: 1, justifyContent: 'center', padding: 24, gap: 16 },
  title: { fontSize: 30, fontWeight: '800', color: '#111' },
  subtitle: { fontSize: 16, color: '#666', marginBottom: 8 },
  serverError: { backgroundColor: '#fff0f0', borderWidth: 1, borderColor: '#fcc', borderRadius: 10, padding: 12, fontSize: 14, color: '#c0392b' },
  btn: { backgroundColor: '#4f86f7', padding: 16, borderRadius: 12, alignItems: 'center', marginTop: 8 },
  btnLoading: { backgroundColor: '#aac4f7' },
  btnText: { color: '#fff', fontWeight: '700', fontSize: 16 },
});

Forgot Password Link

Add a 'Forgot Password?' link below the password field. This is a TouchableOpacity with a Text that calls a navigation callback prop. Position it with alignSelf: 'flex-end' so it appears right-aligned below the password field — a common, familiar placement. The link opens a password-reset flow (entering email → receive a reset link), which is typically a separate screen or modal. Keep the login screen focused on the sign-in flow and delegate password reset to its own component.

import { TouchableOpacity, Text, View, StyleSheet } from 'react-native';

export default function ForgotPasswordLink({ onPress }) {
  return (
    <TouchableOpacity
      onPress={onPress}
      hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}
      style={styles.link}
      accessibilityRole='link'
      accessibilityLabel='Forgot password? Tap to reset'
    >
      <Text style={styles.linkText}>Forgot Password?</Text>
    </TouchableOpacity>
  );
}

const styles = StyleSheet.create({
  link: { alignSelf: 'flex-end' },
  linkText: { color: '#4f86f7', fontSize: 14, fontWeight: '500' },
});

Sign Up Navigation Link

Most login screens include a 'Don't have an account? Sign up' text link at the bottom. Build this as a row containing plain and linked text. Use nested Text components to mix plain text with the tappable link inline. This is preferable to two separate buttons because it looks cleaner and matches the typographic convention users expect. Wrap the tappable portion in a Text with onPress and link styling, nested inside the outer Text.

import { Text, View, StyleSheet } from 'react-native';

export default function SignUpLink({ onNavigate }) {
  return (
    <View style={styles.row}>
      <Text style={styles.text}>
        Don't have an account?{' '}
        <Text
          style={styles.link}
          onPress={onNavigate}
          accessibilityRole='link'
        >
          Sign up
        </Text>
      </Text>
    </View>
  );
}

const styles = StyleSheet.create({
  row: { alignItems: 'center', marginTop: 8 },
  text: { fontSize: 15, color: '#666' },
  link: { color: '#4f86f7', fontWeight: '600' },
});

Loading and Success States

The login flow has several distinct states that need visual treatment. During the API call (isLoading: true), replace the button text with an ActivityIndicator and disable the button to prevent duplicate requests. On success, navigate away immediately — don't show a success message that the user sees briefly before navigation. On failure, show the server error message prominently and re-enable the button so the user can try again. Shake animation on the error card is a polished touch that draws the eye to the error without requiring the user to scroll.

import { ActivityIndicator, TouchableOpacity, Text, StyleSheet } from 'react-native';

export default function SubmitButton({ isLoading, onPress, disabled }) {
  return (
    <TouchableOpacity
      onPress={onPress}
      disabled={disabled || isLoading}
      style={[
        styles.btn,
        (disabled && !isLoading) && styles.btnDisabled,
        isLoading && styles.btnLoading,
      ]}
      activeOpacity={0.8}
    >
      {isLoading
        ? <ActivityIndicator size='small' color='#fff' />
        : <Text style={styles.label}>Sign In</Text>
      }
    </TouchableOpacity>
  );
}

const styles = StyleSheet.create({
  btn: { backgroundColor: '#4f86f7', padding: 16, borderRadius: 12, alignItems: 'center', minHeight: 52 },
  btnDisabled: { backgroundColor: '#c4d8ff' },
  btnLoading: { backgroundColor: '#3a72e0' },
  label: { color: '#fff', fontWeight: '700', fontSize: 16 },
});

Biometric Authentication Option

Enhance the login screen with a biometric authentication option (Face ID / Touch ID / fingerprint) using expo-local-authentication. Check if the device supports biometrics and if the user has enrolled credentials, then show a biometric login button. On success, retrieve the stored credentials from Keychain/SecureStore and perform the login. Biometric login dramatically improves the experience for returning users who don't want to type their password every session.

import * as LocalAuthentication from 'expo-local-authentication';
import * as SecureStore from 'expo-secure-store';
// Install: npx expo install expo-local-authentication expo-secure-store
import { TouchableOpacity, Text } from 'react-native';
import { useEffect, useState } from 'react';

function useBiometrics() {
  const [available, setAvailable] = useState(false);
  useEffect(() => {
    LocalAuthentication.hasHardwareAsync().then(has => {
      if (has) LocalAuthentication.isEnrolledAsync().then(setAvailable);
    });
  }, []);
  return available;
}

export default function BiometricButton({ onSuccess }) {
  const biometricAvailable = useBiometrics();
  if (!biometricAvailable) return null;

  async function authenticate() {
    const result = await LocalAuthentication.authenticateAsync({ promptMessage: 'Sign in to your account' });
    if (result.success) {
      const credentials = await SecureStore.getItemAsync('savedCredentials');
      if (credentials) onSuccess(JSON.parse(credentials));
    }
  }

  return (
    <TouchableOpacity onPress={authenticate} style={{ alignItems: 'center', padding: 16 }}>
      <Text style={{ fontSize: 36 }}>👤</Text>
      <Text style={{ color: '#4f86f7', fontSize: 14, marginTop: 4 }}>Use Face ID</Text>
    </TouchableOpacity>
  );
}

Social Login Buttons

Many apps offer Sign in with Apple and Sign in with Google as alternatives to email/password login. Apple sign-in is required by Apple's App Store guidelines when you offer any third-party social login. Use expo-apple-authentication for Apple and @react-native-google-signin/google-signin for Google. These buttons must follow platform design guidelines — Apple provides a strict button appearance spec. Place social login buttons above the email/password form with an 'OR' divider to give users a clear choice between methods.

import * as AppleAuthentication from 'expo-apple-authentication';
// npx expo install expo-apple-authentication
import { Platform, View, Text, StyleSheet } from 'react-native';

export default function SocialLogins({ onAppleSuccess, onGoogleSuccess }) {
  return (
    <View style={styles.container}>
      {Platform.OS === 'ios' && (
        <AppleAuthentication.AppleAuthenticationButton
          buttonType={AppleAuthentication.AppleAuthenticationButtonType.SIGN_IN}
          buttonStyle={AppleAuthentication.AppleAuthenticationButtonStyle.BLACK}
          cornerRadius={10}
          style={styles.appleBtn}
          onPress={async () => {
            try {
              const credential = await AppleAuthentication.signInAsync({
                requestedScopes: [
                  AppleAuthentication.AppleAuthenticationScope.FULL_NAME,
                  AppleAuthentication.AppleAuthenticationScope.EMAIL,
                ],
              });
              onAppleSuccess(credential);
            } catch (e) {
              if (e.code !== 'ERR_CANCELED') console.error(e);
            }
          }}
        />
      )}
      <View style={styles.divider}>
        <View style={styles.line} />
        <Text style={styles.orText}>OR</Text>
        <View style={styles.line} />
      </View>
    </View>
  );
}

const styles = StyleSheet.create({
  container: { gap: 16 },
  appleBtn: { width: '100%', height: 48 },
  divider: { flexDirection: 'row', alignItems: 'center', gap: 12 },
  line: { flex: 1, height: 1, backgroundColor: '#e0e0e0' },
  orText: { color: '#999', fontWeight: '500' },
});

Quick Check

Test your understanding of React Native Mobile Development concepts from this lesson.

Lesson Recap

In this lesson you learned: validate-on-submit avoids premature error messages during typing, isLoading state disables the submit button and shows a spinner during authentication requests, and FormField wrapper components keep label, input, and error display colocated for reuse across forms. Next up we explore multi-screen navigation with React Navigation.

Frequently asked questions

Is the “Building a Simple Login Form” lesson free?

Yes — the full text of “Building a Simple Login Form” is free to read here on the web, and the React Native Academy course includes 4 lessons in total. To practise it interactively (a built-in code editor and a 24/7 AI tutor) and unlock the rest of the React Native Academy course, upgrade to CoddyKit PRO.

What will I learn in “Building a Simple Login Form”?

Create an email and password form that validates input length, shows an error message for invalid entries, and calls a mock login function on submit. You practise React Native Academy with hands-on code you run directly in the browser, and a 24/7 AI tutor answers your questions as you work through the lesson.

Do I need any experience to start React Native Academy?

No prior experience is required. React Native Academy on CoddyKit is structured for beginners through advanced learners; this is — lesson 4 of 4, so you can start here or from the beginning and move at your own pace.

How long does the “Building a Simple Login Form” lesson take?

Most CoddyKit lessons take about 5–10 minutes. Each one is bite-sized and interactive, so you make steady progress and pick up exactly where you left off across the web and the app.

Can I write and run code in this React Native Academy lesson?

Yes. Every React Native Academy lesson includes a built-in code editor, so you write and run real code right in your browser and get instant AI feedback — no local setup required.

All lessons in this course

  1. TextInput Basics and Keyboard Types
  2. Tap Handlers with TouchableOpacity and Pressable
  3. Toggles, Switches, and Checkboxes
  4. Building a Simple Login Form
← Back to React Native Academy