Lists, forms and input handling
Virtualise long lists with FlatList and SectionList, stabilise keys, add pull to refresh, and build forms that handle the keyboard and validation correctly.
FlatList that stays fast
import { useCallback } from 'react';
import { FlatList, Text, Pressable, StyleSheet, View } from 'react-native';
type Row = { id: string; title: string; subtitle: string };
export function TaskList({
data,
onOpen,
onRefresh,
}: {
data: readonly Row[];
onOpen: (id: string) => void;
onRefresh: () => void;
}) {
const renderItem = useCallback(
({ item }: { item: Row }) => (
<Pressable onPress={() => onOpen(item.id)} style={styles.row}>
<Text style={styles.title}>{item.title}</Text>
<Text style={styles.subtitle} numberOfLines={1}>{item.subtitle}</Text>
</Pressable>
),
[onOpen],
);
const keyExtractor = useCallback((item: Row) => item.id, []);
return (
<FlatList
data={data}
renderItem={renderItem}
keyExtractor={keyExtractor}
initialNumToRender={12}
maxToRenderPerBatch={10}
windowSize={7}
removeClippedSubviews
ItemSeparatorComponent={() => <View style={styles.sep} />}
ListEmptyComponent={<Text style={styles.empty}>Nothing here yet</Text>}
onRefresh={onRefresh}
refreshing={false}
contentContainerStyle={data.length === 0 ? styles.grow : undefined}
/>
);
}
const styles = StyleSheet.create({
row: { paddingVertical: 12 },
title: { fontSize: 16, fontWeight: '600' },
subtitle: { color: '#6b7280' },
sep: { height: StyleSheet.hairlineWidth, backgroundColor: '#e5e7eb' },
empty: { textAlign: 'center', padding: 32, color: '#9ca3af' },
grow: { flexGrow: 1, justifyContent: 'center' },
});keyExtractormust return a stable unique id. An index-based key makes rows jump and lose internal state after a delete or reorder.- Wrap
renderIteminuseCallbackor every keystroke in a search box re-renders every visible row. getItemLayoutfor fixed-height rows lets the list jump to an offset without measuring.- An empty list needs
flexGrow: 1on the content container for the empty component to centre.
Forms and the keyboard
import { Controller, useForm } from 'react-hook-form';
type FormValues = { email: string; age: string; accepted: boolean };
export function SignUpForm({ onSubmit }: { onSubmit: (values: FormValues) => void }) {
const { control, handleSubmit, formState } = useForm<FormValues>({
defaultValues: { email: '', age: '', accepted: false },
mode: 'onBlur',
});
return (
<KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
keyboardVerticalOffset={insets.top + 44}
style={{ flex: 1 }}
>
<ScrollView keyboardShouldPersistTaps="handled" contentContainerStyle={{ padding: 16 }}>
<Controller
control={control}
name="email"
rules={{ required: 'Email is required', pattern: { value: /\S+@\S+\.\S+/, message: 'Invalid email' } }}
render={({ field, fieldState }) => (
<>
<TextInput
value={field.value}
onChangeText={field.onChange}
onBlur={field.onBlur}
keyboardType="email-address"
autoCapitalize="none"
autoComplete="email"
returnKeyType="next"
style={[styles.input, fieldState.error && styles.inputError]}
/>
{fieldState.error && <Text style={styles.error}>{fieldState.error.message}</Text>}
</>
)}
/>
<Pressable
disabled={!formState.isValid || formState.isSubmitting}
onPress={handleSubmit(onSubmit)}
>
<Text>Create account</Text>
</Pressable>
</ScrollView>
</KeyboardAvoidingView>
);
}| Problem | Fix | Why |
|---|---|---|
| Keyboard covers the field | KeyboardAvoidingView | Android needs height, iOS padding |
| First tap only dismisses the keyboard | keyboardShouldPersistTaps="handled" | Otherwise the tap is consumed |
| Android resizes the window | windowSoftInputMode in the manifest | Adjust resize or pan, not both |
| Autofill guesses wrong | autoComplete and textContentType | Set both for iOS and Android |
Pressable and gesture feedback
import { Pressable, Text } from 'react-native';
export function Button({ label, onPress, disabled }: { label: string; onPress: () => void; disabled?: boolean }) {
return (
<Pressable
onPress={onPress}
disabled={disabled}
hitSlop={8}
android_ripple={{ color: 'rgba(0,0,0,0.12)' }}
style={({ pressed }) => [
{ paddingVertical: 12, paddingHorizontal: 20, borderRadius: 10, opacity: disabled ? 0.4 : pressed ? 0.85 : 1 },
]}
>
<Text style={{ textAlign: 'center', fontWeight: '600' }}>{label}</Text>
</Pressable>
);
}⚠️
Minimum tap target is 44 by 44 points on iOS and 48 by 48 density-independent pixels on Android. A small icon without
hitSlop is technically clickable and practically unusable.FAQ
Should I use FlatList or FlashList?
FlatList is built in and fine for most feeds. FlashList from Shopify recycles views more aggressively and is measurably faster for very long or image-heavy lists, at the cost of an extra dependency and stricter item sizing.
Why does my form state reset when I scroll?
The component unmounted — usually because a parent re-created it or the list recycled the row. Lift the form state above the scroll container, or use a form library whose state lives outside the render tree.
Related
Flexbox layout and responsive design Networking, data fetching and offline support
Last refreshed 2026-09-18.