Login + register screens
Forms + validation + errors.
Build the login + register screens β forms, validation, error states. The Grit mobile scaffold ships these pre-built; this lesson explains every piece so you can customize them.
The screens
app/(auth)/_layout.tsx wrapper β centered card, no tabsapp/(auth)/login.tsx email + passwordapp/(auth)/register.tsx email + name + password
The form pattern
import { useState } from 'react'import { View, TextInput, Text, Pressable } from 'react-native'import { router } from 'expo-router'import { useAuth } from '@/hooks/use-auth'export default function LoginScreen() {const { login, isLoading } = useAuth()const [email, setEmail] = useState('')const [password, setPassword] = useState('')const [error, setError] = useState<string | null>(null)async function onSubmit() {setError(null)try {await login(email, password)router.replace('/(tabs)')} catch (err) {if (err.code === 'INVALID_CREDENTIALS') setError('Wrong email or password.')else setError(err.message)}}return (<View className="flex-1 p-6"><Text className="text-2xl font-semibold mb-6">Sign in</Text><TextInputautoCapitalize="none"keyboardType="email-address"textContentType="emailAddress"autoComplete="email"placeholder="Email"value={email}onChangeText={setEmail}className="border rounded p-3 mb-3"/><TextInputsecureTextEntryautoComplete="password"textContentType="password"placeholder="Password"value={password}onChangeText={setPassword}className="border rounded p-3 mb-3"/>{error && <Text className="text-red-500 mb-3">{error}</Text>}<PressableonPress={onSubmit}disabled={isLoading}className="bg-blue-600 rounded p-3 items-center"><Text className="text-white">{isLoading ? 'Signing inβ¦' : 'Sign in'}</Text></Pressable></View>)}
The bits that matter for mobile UX
autoCapitalize="none"on the email field β otherwise iOS capitalises the first letterkeyboardType="email-address"β shows the email keyboard with@readily accessibleautoComplete + textContentTypeβ triggers the OS password autofill from 1Password / iCloud Keychain / BitwardensecureTextEntryon the password field β masks input, disables autocorrect, disables screenshots in some OS configs
KeyboardAvoidingView + ScrollView so the focused input is always visible above the keyboard. The scaffold does this automatically.Validation with shared Zod schemas
From the previous chapter, you have LoginSchema in packages/shared/src/schemas/. Use it client-side too:
import { LoginSchema } from '@grit/shared/schemas/auth'async function onSubmit() {const result = LoginSchema.safeParse({ email, password })if (!result.success) {setError(result.error.flatten().fieldErrors.email?.[0] ?? 'Invalid input')return}await login(email, password)}
Now the same validation runs on mobile, web, and on the API. Bad input never reaches the server.
Useful UX touches
- Show a loading spinner on the button instead of the text. Use
ActivityIndicatorfrom React Native. - Disable the button while submitting to prevent double-taps.
- Auto-focus the email field on mount with
autoFocus={true}. - Show inline field errors below each input, not just a single error string at the top.
Quick check
Try it
On your scaffolded mobile app, sign up a new user and sign in:
- Run the API (
cd apps/api && go run ./cmd/server) - Run the mobile app (
cd apps/mobile && pnpm dev) - Open the app on simulator or Expo Go
- Navigate to
/register, create an account - You should land on the home screen with the user list
Paste a screenshot in notes.md.
What's next
Tokens stored where? SecureStore + Keychain β the OS-level secret store. Next lesson.
Spot a typo? Have an idea?
Help us improve this lesson. One click opens a GitHub issue with the lesson URL pre-filled β suggest clearer wording, report a bug, or request more depth. The course keeps improving thanks to learners like you.
Suggest an improvement on GitHub