Almost every real app needs a login or sign-up form, and the data it handles is sensitive. This lesson shows how to build forms that give clear feedback, where to store a login token, and how to protect screens. It uses short examples that you can add to the apps you built. It does not need a project of its own.
Forms and validation
A form is state plus rules. Keep each field in state, validate on submit (and on blur for a nicer feel) and show the message next to the field. Return errors from a plain function so you can test it.
export type Errors = { email?: string; password?: string };
export function validateLogin(email: string, password: string): Errors {
const errors: Errors = {};
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email.trim())) errors.email = "Enter a valid email";
if (password.length < 8) errors.password = "Use at least 8 characters";
return errors;
}
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [errors, setErrors] = useState<Errors>({});
const submit = () => {
const found = validateLogin(email, password);
setErrors(found);
if (Object.keys(found).length === 0) signIn(email.trim(), password);
};
<TextInput value={email} onChangeText={setEmail} keyboardType="email-address"
autoCapitalize="none" autoComplete="email" placeholder="Email" />
{errors.email && <Text style={{ color: "#c92a2a" }}>{errors.email}</Text>}
<TextInput value={password} onChangeText={setPassword} secureTextEntry
autoComplete="password" placeholder="Password" />
{errors.password && <Text style={{ color: "#c92a2a" }}>{errors.password}</Text>}
Use the right keyboard type, turn off auto-capitalisation for emails, and secureTextEntry for passwords. Wrap long forms in KeyboardAvoidingView and a ScrollView with keyboardShouldPersistTaps="handled" so the keyboard does not hide the fields. For big forms, libraries such as React Hook Form with a schema validator like Zod save work.
Client-side checks are for user experience only. The server must validate everything again, because anyone can send requests without your app.
Where to store a token
After login, your server returns a token that proves who the user is. AsyncStorage is not encrypted, so it is not the place for it. Use expo-secure-store, which keeps values in the iOS Keychain and the Android Keystore.
import * as SecureStore from "expo-secure-store";
const TOKEN = "auth.token";
export const saveToken = (t: string) => SecureStore.setItemAsync(TOKEN, t);
export const readToken = () => SecureStore.getItemAsync(TOKEN);
export const clearToken = () => SecureStore.deleteItemAsync(TOKEN);
Install it with npx expo install expo-secure-store. Keep tokens short-lived and refresh them as your backend allows. Send them in a header, never in the URL: fetch(url, { headers: { Authorization: \Bearer \${token}\ } }).
Protecting screens
A simple pattern with Expo Router keeps signed-in screens in one group and redirects when there is no session. Keep the session in a small Context or Zustand store that loads the token at start.
// app/(app)/_layout.tsx -- only signed-in users reach these screens
import { Redirect, Stack } from "expo-router";
import { useSession } from "../../src/session";
export default function AppLayout() {
const { token, loading } = useSession();
if (loading) return null; // or a splash screen
if (!token) return <Redirect href="/login" />;
return <Stack />;
}
Files under app/(app)/ are protected, and app/login.tsx stays open. The parentheses make a group that does not appear in the URL.
Security habits
- Never put secrets, private keys or API keys with write power in the app.
- Use HTTPS only.
- Never log passwords or tokens.
- Do not trust data from the client on the server.
- For social login, use the provider's official method (such as expo-auth-session) rather than building your own.
Your task
- Add the validation function and form to a new login screen in one of your projects.
- Write three tests for
validateLogin(bad email, short password, valid input). - Store a fake token with SecureStore after pressing Sign in and read it on app start.
- Make a protected group and check that you are redirected when no token exists.
How to check it worked
- Invalid input shows messages without a request being sent.
- The token is saved in secure storage, not AsyncStorage.
- Protected screens redirect to the login screen.