Why My Form Forgot the Input and Sent It Twice
You fill out an enquiry or registration form in a mobile app, tap the submit button, and a generic red banner pops up: "Submission failed". When you look back down at the screen, every field you just typed has been reset to an empty string. You have to type your name, phone, email, and description all over again.
Even worse, checking your backend logs reveals two identical accounts or two duplicate orders created half a second apart because you double-tapped the submit button when the screen paused.
Show each error next to the field that can fix it, keep every typed value after a failed submit, and let the shared button block the second tap. The server still needs an idempotency key.
This is Day 8 of Ship Native, the second lesson in our three-part series on Forms and uploads. Yesterday we resolved Android 15 edge-to-edge window keyboard issues. Today we examine form lifecycle architecture: inline validation, input retention, button guards, and the real-world bug where a double-tap guard lied to the user.
The video uses schematic illustrations, English synthetic narration, and synchronized bilingual subtitles. The code examples below are reviewed production patterns without physical test device execution.
The problem with global error banners
When a form submission fails, displaying a global toast banner at the top of the screen creates severe user friction:
- It hides context. A banner reading "Invalid input" fails to tell the user which of the five inputs failed validation.
- It requires guesswork. The user must inspect every field to discover whether their phone format was rejected or their password was too short.
- It disconnects error from action. The error notification is at the very top of the screen, while the input that needs correction is halfway down.
In React Native, each field must own its own validation state using two values:
touched.fieldName: a boolean flag indicating that the user interacted with and left the input.errors.fieldName: a string containing the specific failure message.
Rendering {touched.fieldName && errors.fieldName ? <Text style={styles.error}>{errors.fieldName}</Text> : null} ensures the error appears directly beneath the input that can fix it, and only after the user has attempted to complete that field.
Validation timing: validate on blur, not on change
When configuring Formik and Yup, validation timing makes or breaks user experience:
<Formik
initialValues={initialValues}
validationSchema={validationSchema}
validateOnBlur={true}
validateOnChange={false}
onSubmit={handleSubmit}
>
Validating on every keystroke (validateOnChange={true}) annoys users by flashing red error messages before they finish typing the first three characters of their email address.
Setting validateOnBlur={true} validates fields only when the user finishes and moves focus elsewhere. When the user eventually taps the submit button, Formik automatically touches all fields and runs validation across the entire schema simultaneously.
Retaining typed inputs across API failures
A common anti-pattern in custom form implementations is resetting state on error:
// Flawed error handler: wipes user effort
catch (err) {
setFormValues(initialValues); // Never do this!
showToast('Submission failed');
}
When an API call fails due to a network drop or server error, all typed values must stay in form state. Formik retains field values by default unless resetForm() is explicitly called. Keep values intact so the user only has to correct the failing field rather than retyping valid data.
When the server returns validation errors (such as an HTTP 422 response with errors: { email: 'Email already registered' }), map those errors back into Formik using helpers.setFieldError('email', message). The user sees server-side constraints inline right beside the relevant input.
The shared button double-tap guard
Mobile touch screens register taps rapidly. When network requests introduce a brief latency, users frequently tap the submit button a second time.
Sprinkling boolean flags (let isSubmitting = false) across dozens of screen files is fragile. The double-tap guard belongs in your shared button component:
import React from 'react';
import {
ActivityIndicator,
StyleSheet,
Text,
TouchableOpacity,
TouchableOpacityProps,
ViewStyle,
TextStyle,
} from 'react-native';
export interface AppButtonProps extends TouchableOpacityProps {
title: string;
loading?: boolean;
disabled?: boolean;
style?: ViewStyle;
textStyle?: TextStyle;
}
/**
* Shared Button component.
* Evidence: Jeyad/src/components/AppButton/index.tsx:65
*
*
* Crucial Rule: The double-tap guard belongs in the shared button component once.
* Setting `disabled={disabled || loading}` blocks all touch events at the native
* level as soon as a request starts, preventing duplicate submissions across every form.
*/
export const AppButton: React.FC<AppButtonProps> = ({
title,
loading = false,
disabled = false,
onPress,
style,
textStyle,
...rest
}) => {
const isBlocked = disabled || loading;
return (
<TouchableOpacity
activeOpacity={0.7}
disabled={isBlocked}
onPress={onPress}
style={[
styles.button,
isBlocked && styles.buttonDisabled,
style,
]}
accessibilityRole="button"
accessibilityState={{ disabled: isBlocked, busy: loading }}
{...rest}
>
{loading ? (
<ActivityIndicator size="small" color="#07100b" />
) : (
<Text style={[styles.title, textStyle]}>{title}</Text>
)}
</TouchableOpacity>
);
};
const styles = StyleSheet.create({
button: {
backgroundColor: '#22c55e',
borderRadius: 10,
paddingVertical: 14,
paddingHorizontal: 20,
alignItems: 'center',
justifyContent: 'center',
minHeight: 48,
},
buttonDisabled: {
opacity: 0.6,
},
title: {
color: '#07100b',
fontSize: 16,
fontWeight: '700',
},
});
By passing disabled={disabled || loading} to the root TouchableOpacity, native touch handling is blocked immediately upon initiation of the request. A spinner replaces the button title, providing instant visual feedback.
Add an explicit check in the submit handler as a secondary safeguard:
const handleSubmit = async (values: FormValues, helpers: FormikHelpers<FormValues>) => {
if (helpers.isSubmitting) return;
// proceed to submit...
};
Case study: The guard that lied to the user
In our production application niyak-app, we encountered a subtle bug caused by combining validation logic with in-flight request guards:
/**
* Production Mistake Case Study: The Guard That Lies
* Evidence: niyak-app/src/screens/Authentication/OtpVerification/index.tsx:202
*
* The Bug:
* When testing on slow networks, impatient users double-tap the confirm button.
* The handler checked:
* if (code.length !== OTP_LENGTH || isSubmitting) {
* setApiErrors({ code: ['OTP is not valid'] });
* return;
* }
*
* Because `isSubmitting` was bundled together with the length validation:
* 1. The first tap started the valid network request and set `isSubmitting = true`.
* 2. The second tap entered this branch because `isSubmitting` was true.
* 3. The code then erroneously set `setApiErrors({ code: ['OTP is not valid'] })`!
* The user saw "OTP is not valid" even though their OTP was 100% correct,
* creating huge confusion and support tickets.
*/
export interface VerificationState {
code: string;
isSubmitting: boolean;
errors: Record<string, string[]>;
}
const OTP_LENGTH = 6;
// The Flawed Handler from niyak-app:
export function flawedConfirmPress(
state: VerificationState,
setApiErrors: (err: Record<string, string[]>) => void
) {
// BUG: Combines validation failure with in-flight guard!
if (state.code.length !== OTP_LENGTH || state.isSubmitting) {
setApiErrors({ code: ['OTP is not valid'] });
return false;
}
// Proceeds to submit...
return true;
}
// The Correct Handler:
export function correctConfirmPress(
state: VerificationState,
setApiErrors: (err: Record<string, string[]>) => void
) {
// 1. Silent early exit for in-flight requests: do not lie to the user!
if (state.isSubmitting) {
return false;
}
// 2. Validate input on its own merits:
if (state.code.length !== OTP_LENGTH) {
setApiErrors({ code: ['OTP must be 6 digits'] });
return false;
}
// Proceeds to submit safely...
return true;
}
In niyak-app/src/screens/Authentication/OtpVerification/index.tsx:202, the handler checked:
if (code.length !== OTP_LENGTH || isSubmitting)
When an impatient user double-tapped the verify button:
- The first tap set
isSubmitting = trueand started the API request. - The second tap entered the
ifstatement becauseisSubmittingwas true. - The block executed
setApiErrors({ code: ['OTP is not valid'] })!
The user saw a red banner stating their OTP was invalid, even when their code was completely correct.
Always separate request guards from validation checks. If a request is already in progress, exit silently without modifying validation state.
Client guards still need server idempotency
A client-side button guard only prevents accidental immediate double taps. It cannot prevent duplicate records under real-world mobile network conditions:
- The client sends a request.
- The server processes the mutation and inserts the record.
- The mobile radio switches cell towers or drops connection before receiving the HTTP 200 response.
- The client or user retries the request over the newly restored connection.
Because the second request is a fresh HTTP call, client-side guards are powerless.
The durable backend solution is an Idempotency-Key header:
const idempotencyKey = `req_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
fetch('https://api.example.com/v1/enquiry', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Idempotency-Key': idempotencyKey,
},
body: JSON.stringify(values),
});
When the server receives an idempotency key, it caches the result of that operation. If a duplicate request arrives with the same key, the server returns the cached response without re-executing the mutation.
Complete implementation
import React, { useState } from 'react';
import {
StyleSheet,
Text,
TextInput,
View,
ScrollView,
SafeAreaView,
} from 'react-native';
import { Formik, FormikHelpers } from 'formik';
import * as Yup from 'yup';
import { AppButton } from './AppButton';
export interface FormValues {
name: string;
phone: string;
email: string;
message: string;
}
const validationSchema = Yup.object().shape({
name: Yup.string()
.trim()
.min(2, 'Name must be at least 2 characters')
.required('Name is required'),
phone: Yup.string()
.trim()
.matches(/^\+?[0-9]{9,15}$/, 'Enter a valid phone number')
.required('Phone is required'),
email: Yup.string()
.trim()
.email('Enter a valid email address')
.required('Email is required'),
message: Yup.string()
.trim()
.min(10, 'Message must be at least 10 characters')
.required('Message is required'),
});
const initialValues: FormValues = {
name: '',
phone: '',
email: '',
message: '',
};
export function SafeFormSubmit() {
const [globalError, setGlobalError] = useState<string | null>(null);
const [submitSuccess, setSubmitSuccess] = useState(false);
const handleSubmit = async (
values: FormValues,
helpers: FormikHelpers<FormValues>
) => {
// 1. Clear previous errors
setGlobalError(null);
// 2. Generate a client-side idempotency key for this submission attempt.
// If a mobile network timeout triggers a retry, the backend recognizes
// the key and does not insert a duplicate record.
const idempotencyKey = `req_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
try {
// Simulated API POST request
const response = await fetch('https://api.example.com/v1/enquiry', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Idempotency-Key': idempotencyKey,
},
body: JSON.stringify(values),
});
if (!response.ok) {
if (response.status === 422) {
// Map backend field-specific validation errors:
const data = await response.json();
if (data.errors) {
Object.entries(data.errors).forEach(([field, msg]) => {
helpers.setFieldError(field as keyof FormValues, String(msg));
});
return;
}
}
throw new Error('Server request failed. Please check your data.');
}
setSubmitSuccess(true);
} catch (err: any) {
// On failure, typed values stay safely in Formik state!
// We only display a concise message; the user loses zero typed work.
setGlobalError(err.message || 'Something went wrong. Values preserved.');
} finally {
// Always reset the in-flight submitting flag
helpers.setSubmitting(false);
}
};
return (
<SafeAreaView style={styles.container}>
<Formik
initialValues={initialValues}
validationSchema={validationSchema}
validateOnBlur={true}
validateOnChange={false}
onSubmit={handleSubmit}
>
{({
handleChange,
handleBlur,
handleSubmit: triggerSubmit,
values,
errors,
touched,
isSubmitting,
isValid,
}) => (
<ScrollView
contentContainerStyle={styles.scrollContent}
keyboardShouldPersistTaps="handled"
>
<Text style={styles.heading}>Enquiry Form</Text>
<Text style={styles.subheading}>
Submit your project details without losing input or double-posting.
</Text>
{globalError ? (
<View style={styles.globalErrorBanner}>
<Text style={styles.globalErrorText}>{globalError}</Text>
</View>
) : null}
{submitSuccess ? (
<View style={styles.successBanner}>
<Text style={styles.successText}>Enquiry submitted successfully!</Text>
</View>
) : null}
{/* Name Field */}
<Text style={styles.label}>Full Name</Text>
<TextInput
style={[
styles.input,
touched.name && errors.name ? styles.inputError : null,
]}
value={values.name}
onChangeText={handleChange('name')}
onBlur={handleBlur('name')}
placeholder="Ada Lovelace"
placeholderTextColor="#68778d"
/>
{touched.name && errors.name ? (
<Text style={styles.fieldError}>{errors.name}</Text>
) : null}
{/* Phone Field */}
<Text style={styles.label}>Phone Number</Text>
<TextInput
style={[
styles.input,
touched.phone && errors.phone ? styles.inputError : null,
]}
value={values.phone}
onChangeText={handleChange('phone')}
onBlur={handleBlur('phone')}
placeholder="+201000000000"
placeholderTextColor="#68778d"
keyboardType="phone-pad"
/>
{touched.phone && errors.phone ? (
<Text style={styles.fieldError}>{errors.phone}</Text>
) : null}
{/* Email Field */}
<Text style={styles.label}>Email Address</Text>
<TextInput
style={[
styles.input,
touched.email && errors.email ? styles.inputError : null,
]}
value={values.email}
onChangeText={handleChange('email')}
onBlur={handleBlur('email')}
placeholder="[email protected]"
placeholderTextColor="#68778d"
keyboardType="email-address"
autoCapitalize="none"
/>
{touched.email && errors.email ? (
<Text style={styles.fieldError}>{errors.email}</Text>
) : null}
{/* Message Field */}
<Text style={styles.label}>Project Scope</Text>
<TextInput
style={[
styles.input,
styles.multilineInput,
touched.message && errors.message ? styles.inputError : null,
]}
value={values.message}
onChangeText={handleChange('message')}
onBlur={handleBlur('message')}
placeholder="Tell us what you want to build..."
placeholderTextColor="#68778d"
multiline
numberOfLines={4}
/>
{touched.message && errors.message ? (
<Text style={styles.fieldError}>{errors.message}</Text>
) : null}
<View style={styles.buttonWrapper}>
<AppButton
title="Send Enquiry"
loading={isSubmitting}
disabled={isSubmitting || !isValid}
onPress={() => triggerSubmit()}
/>
</View>
</ScrollView>
)}
</Formik>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#0f141c',
},
scrollContent: {
padding: 20,
},
heading: {
fontSize: 26,
fontWeight: '700',
color: '#f3f1ea',
marginBottom: 6,
},
subheading: {
fontSize: 15,
color: '#8e9baa',
marginBottom: 20,
},
globalErrorBanner: {
backgroundColor: '#382525',
borderWidth: 1,
borderColor: '#e05345',
borderRadius: 8,
padding: 12,
marginBottom: 16,
},
globalErrorText: {
color: '#ffb58d',
fontSize: 14,
fontWeight: '600',
},
successBanner: {
backgroundColor: '#1b3327',
borderWidth: 1,
borderColor: '#22c55e',
borderRadius: 8,
padding: 12,
marginBottom: 16,
},
successText: {
color: '#a8edc4',
fontSize: 14,
fontWeight: '600',
},
label: {
fontSize: 14,
fontWeight: '600',
color: '#cfd8dc',
marginBottom: 6,
},
input: {
backgroundColor: '#1b222d',
borderWidth: 1,
borderColor: '#2b3545',
borderRadius: 10,
color: '#f3f1ea',
fontSize: 16,
paddingHorizontal: 14,
paddingVertical: 12,
marginBottom: 4,
},
inputError: {
borderColor: '#e05345',
},
multilineInput: {
height: 100,
textAlignVertical: 'top',
},
fieldError: {
color: '#ff8d8d',
fontSize: 13,
fontWeight: '500',
marginBottom: 12,
marginLeft: 2,
},
buttonWrapper: {
marginTop: 16,
marginBottom: 32,
},
});
Summary
- Show errors under the field that can fix them using
touched.x && errors.x. - Set
validateOnBlur={true}and avoid aggressive keystroke validation. - Never reset form values on an API error; map backend 422 errors onto fields with
setFieldError. - Move the primary double-tap guard into your shared button component with
disabled={disabled || loading}. - Exit quietly on in-flight requests; never set validation error messages during a submission guard.
- Send an
Idempotency-Keyheader with mutations so the backend can deduplicate retries.
What comes next
Next: Why Save Kept Spinning: Request Timeouts and Offline States, Day 9, Production fixes. Its video link will be added when published.
Comments