Forms
Forms are a common feature of any application. In this section, we will show you how you can handle form the right way with the starter.
react-hook-form
The starter uses react-hook-form to handle forms. It is a popular library that provides a lot of features out of the box. It is also very easy to use and integrate with React Native.
Make sure to check the react-hook-form documentation to learn more about how to use it.
As we mention in the components section of the documentation here, we create a set of controlled components that are only used with react-hook-form. The starter only provides two components: ControlledInput
and ControlledSelect
but you can easily create other components using the same approach.
Here is the complete code of our ControlledInput
when we use useController
hook from react-hook-form to handle form state and validation rules:
import * as React from 'react';import type { Control, FieldValues, Path, RegisterOptions,} from 'react-hook-form';import { useController } from 'react-hook-form';import type { TextInput, TextInputProps } from 'react-native';import { I18nManager, StyleSheet, View } from 'react-native';import { TextInput as NTextInput } from 'react-native';import { tv } from 'tailwind-variants';
import colors from './colors';import { Text } from './text';
const inputTv = tv({ slots: { container: 'mb-2', label: 'text-grey-100 mb-1 text-lg dark:text-neutral-100', input: 'mt-0 rounded-xl border-[0.5px] border-neutral-300 bg-neutral-100 px-4 py-3 font-inter text-base font-medium leading-5 dark:border-neutral-700 dark:bg-neutral-800 dark:text-white', },
variants: { focused: { true: { input: 'border-neutral-400 dark:border-neutral-300', }, }, error: { true: { input: 'border-danger-600', label: 'text-danger-600 dark:text-danger-600', }, }, disabled: { true: { input: 'bg-neutral-200', }, }, }, defaultVariants: { focused: false, error: false, disabled: false, },});
export interface NInputProps extends TextInputProps { label?: string; disabled?: boolean; error?: string;}
type TRule = Omit< RegisterOptions, 'valueAsNumber' | 'valueAsDate' | 'setValueAs'>;
export type RuleType<T> = { [name in keyof T]: TRule };export type InputControllerType<T extends FieldValues> = { name: Path<T>; control: Control<T>; rules?: TRule;};
interface ControlledInputProps<T extends FieldValues> extends NInputProps, InputControllerType<T> {}
export const Input = React.forwardRef<TextInput, NInputProps>((props, ref) => { const { label, error, testID, ...inputProps } = props; const [isFocussed, setIsFocussed] = React.useState(false); const onBlur = React.useCallback(() => setIsFocussed(false), []); const onFocus = React.useCallback(() => setIsFocussed(true), []);
const styles = React.useMemo( () => inputTv({ error: Boolean(error), focused: isFocussed, disabled: Boolean(props.disabled), }), [error, isFocussed, props.disabled] );
return ( <View className={styles.container()}> {label && ( <Text testID={testID ? `${testID}-label` : undefined} className={styles.label()} > {label} </Text> )} <NTextInput testID={testID} ref={ref} placeholderTextColor={colors.neutral[400]} className={styles.input()} onBlur={onBlur} onFocus={onFocus} {...inputProps} style={StyleSheet.flatten([ { writingDirection: I18nManager.isRTL ? 'rtl' : 'ltr' }, inputProps.style, ])} /> {error && ( <Text testID={testID ? `${testID}-error` : undefined} className="text-sm text-danger-400 dark:text-danger-600" > {error} </Text> )} </View> );});
// only used with react-hook-formexport function ControlledInput<T extends FieldValues>( props: ControlledInputProps<T>) { const { name, control, rules, ...inputProps } = props;
const { field, fieldState } = useController({ control, name, rules }); return ( <Input ref={field.ref} autoCapitalize="none" onChangeText={field.onChange} value={(field.value as string) || ''} {...inputProps} error={fieldState.error?.message} /> );}
If you want to create your own controlled component, you just need to make sure your component props type extends from InputControllerType
the same way we are using it with ControlledInput
.
Here is another example of a Select input we create using the same approach as ControlledInput
:
/* eslint-disable max-lines-per-function */import { BottomSheetFlatList, type BottomSheetModal,} from '@gorhom/bottom-sheet';import { FlashList } from '@shopify/flash-list';import { useColorScheme } from 'nativewind';import * as React from 'react';import type { FieldValues } from 'react-hook-form';import { useController } from 'react-hook-form';import { Platform, TouchableOpacity, View } from 'react-native';import { Pressable, type PressableProps } from 'react-native';import type { SvgProps } from 'react-native-svg';import Svg, { Path } from 'react-native-svg';import { tv } from 'tailwind-variants';
import colors from '@/ui/colors';import { CaretDown } from '@/ui/icons';
import type { InputControllerType } from './input';import { useModal } from './modal';import { Modal } from './modal';import { Text } from './text';
const selectTv = tv({ slots: { container: 'mb-4', label: 'text-grey-100 dark:text-neutral-100 text-lg mb-1', input: 'mt-0 flex-row items-center justify-center border-[0.5px] border-grey-50 px-3 py-3 rounded-xl dark:bg-neutral-800 dark:border-neutral-500', inputValue: 'dark:text-neutral-100', },
variants: { focused: { true: { input: 'border-neutral-600', }, }, error: { true: { input: 'border-danger-600', label: 'text-danger-600 dark:text-danger-600', inputValue: 'text-danger-600', }, }, disabled: { true: { input: 'bg-neutral-200', }, }, }, defaultVariants: { error: false, disabled: false, },});
const List = Platform.OS === 'web' ? FlashList : BottomSheetFlatList;
export type Option = { label: string; value: string | number };
type OptionsProps = { options: Option[]; onSelect: (option: Option) => void; value?: string | number; testID?: string;};
function keyExtractor(item: Option) { return `select-item-${item.value}`;}
export const Options = React.forwardRef<BottomSheetModal, OptionsProps>( ({ options, onSelect, value, testID }, ref) => { const height = options.length * 70 + 100; const snapPoints = React.useMemo(() => [height], [height]); const { colorScheme } = useColorScheme(); const isDark = colorScheme === 'dark';
const renderSelectItem = React.useCallback( ({ item }: { item: Option }) => ( <Option key={`select-item-${item.value}`} label={item.label} selected={value === item.value} onPress={() => onSelect(item)} testID={testID ? `${testID}-item-${item.value}` : undefined} /> ), [onSelect, value, testID] );
return ( <Modal ref={ref} index={0} snapPoints={snapPoints} backgroundStyle={{ backgroundColor: isDark ? colors.neutral[800] : colors.white, }} > <List data={options} keyExtractor={keyExtractor} renderItem={renderSelectItem} testID={testID ? `${testID}-modal` : undefined} estimatedItemSize={52} /> </Modal> ); });
const Option = React.memo( ({ label, selected = false, ...props }: PressableProps & { selected?: boolean; label: string; }) => { return ( <Pressable className="flex-row items-center border-b-[1px] border-neutral-300 bg-white px-3 py-2 dark:border-neutral-700 dark:bg-neutral-800" {...props} > <Text className="flex-1 dark:text-neutral-100 ">{label}</Text> {selected && <Check />} </Pressable> ); });
export interface SelectProps { value?: string | number; label?: string; disabled?: boolean; error?: string; options?: Option[]; onSelect?: (value: string | number) => void; placeholder?: string; testID?: string;}interface ControlledSelectProps<T extends FieldValues> extends SelectProps, InputControllerType<T> {}
export const Select = (props: SelectProps) => { const { label, value, error, options = [], placeholder = 'select...', disabled = false, onSelect, testID, } = props; const modal = useModal();
const onSelectOption = React.useCallback( (option: Option) => { onSelect?.(option.value); modal.dismiss(); }, [modal, onSelect] );
const styles = React.useMemo( () => selectTv({ error: Boolean(error), disabled, }), [error, disabled] );
const textValue = React.useMemo( () => value !== undefined ? options?.filter((t) => t.value === value)?.[0]?.label ?? placeholder : placeholder, [value, options, placeholder] );
return ( <> <View className={styles.container()}> {label && ( <Text testID={testID ? `${testID}-label` : undefined} className={styles.label()} > {label} </Text> )} <TouchableOpacity className={styles.input()} disabled={disabled} onPress={modal.present} testID={testID ? `${testID}-trigger` : undefined} > <View className="flex-1"> <Text className={styles.inputValue()}>{textValue}</Text> </View> <CaretDown /> </TouchableOpacity> {error && ( <Text testID={`${testID}-error`} className="text-sm text-danger-300 dark:text-danger-600" > {error} </Text> )} </View> <Options testID={testID} ref={modal.ref} options={options} onSelect={onSelectOption} /> </> );};
// only used with react-hook-formexport function ControlledSelect<T extends FieldValues>( props: ControlledSelectProps<T>) { const { name, control, rules, onSelect: onNSelect, ...selectProps } = props;
const { field, fieldState } = useController({ control, name, rules }); const onSelect = React.useCallback( (value: string | number) => { field.onChange(value); onNSelect?.(value); }, [field, onNSelect] ); return ( <Select onSelect={onSelect} value={field.value} error={fieldState.error?.message} {...selectProps} /> );}
const Check = ({ ...props }: SvgProps) => ( <Svg width={25} height={24} fill="none" viewBox="0 0 25 24" {...props} className="stroke-black dark:stroke-white" > <Path d="m20.256 6.75-10.5 10.5L4.506 12" strokeWidth={2.438} strokeLinecap="round" strokeLinejoin="round" /> </Svg>);
Use Case
Let’s say you want to create a form that allows the user to log in to the application. You will need to create a screen that contains the form with email and password fields, as well as a submit button. The form will need to be validated, and the data will need to be sent to the backend. Here’s how you can do it:
**Step 1: Create your schema validation **
The right way to validate a form is to create a schema validation. You can use any library you want but we recommend using zod as you can easily infer the types from the schema. Here is how you can create a schema validation for the login form:
import * as z from 'zod';
const schema = z.object({ email: z.string().email(), password: z.string().min(6),});
type FormType = z.infer<typeof schema>;
Step 2: Create your form component
Now that you have your schema validation, you can easily create your login screen using react-hook-form and the controlled components we already have. Here is how you can create your login screen:
import { zodResolver } from '@hookform/resolvers/zod';import React from 'react';import { useForm } from 'react-hook-form';import * as z from 'zod';
import { useAuth } from '@/core';import { Button, ControlledInput, View } from '@/ui';
const schema = z.object({ email: z.string().email(), password: z.string().min(6),});
type FormType = z.infer<typeof schema>;
export const Login = () => { const { signIn } = useAuth();
const { handleSubmit, control } = useForm<FormType>({ resolver: zodResolver(schema), });
const onSubmit = (data: FormType) => { console.log(data); signIn({ access: 'access-token', refresh: 'refresh-token' }); }; return ( <View className="flex-1 justify-center p-4"> <ControlledInput control={control} name="email" label="Email" /> <ControlledInput control={control} name="password" label="Password" placeholder="***" secureTextEntry={true} /> <Button label="Login" onPress={handleSubmit(onSubmit)} variant="primary" /> </View> );};
Done ! You have created a form with validation and typescript support.
Handling Keyboard
The template comes with react-native-avoid-softinput
pre-installed and configured to handle the keyboard. You only need to use the useSoftKeyboardEffect
hook on your screen, and you’re good to go.
import React from 'react';import { useSoftKeyboardEffect } from '@/core/keyboard';import { LoginForm } from './login-form';
export const Login = () => { useSoftKeyboardEffect(); return <LoginForm />;};