Skip to content

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:

src/ui/input.tsx
import React, { forwardRef, useCallback, useMemo, useState } from 'react';
import type {
Control,
FieldValues,
Path,
RegisterOptions,
} from 'react-hook-form';
import { useController } from 'react-hook-form';
import type { TextInputProps } from 'react-native';
import { I18nManager, StyleSheet, TextInput as NTextInput,View } 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<T extends FieldValues> =
| Omit<
RegisterOptions<T>,
'disabled' | 'valueAsNumber' | 'valueAsDate' | 'setValueAs'
>
| undefined;
export type RuleType<T extends FieldValues> = { [name in keyof T]: TRule<T> };
export type InputControllerType<T extends FieldValues> = {
name: Path<T>;
control: Control<T>;
rules?: RuleType<T>;
};
interface ControlledInputProps<T extends FieldValues>
extends NInputProps,
InputControllerType<T> {}
export const Input = forwardRef<NTextInput, NInputProps>((props, ref) => {
const { label, error, testID, ...inputProps } = props;
const [isFocussed, setIsFocussed] = useState(false);
const onBlur = useCallback(() => setIsFocussed(false), []);
const onFocus = useCallback(() => setIsFocussed(true), []);
const styles = 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' },
{ textAlign: I18nManager.isRTL ? 'right' : 'left' },
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-form
export 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:

src/ui/select.tsx
/* 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 { forwardRef, memo, useCallback, useMemo } from 'react';
import type { FieldValues } from 'react-hook-form';
import { useController } from 'react-hook-form';
import { Platform, Pressable, type PressableProps, View } 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 { Modal, useModal } from './modal';
import { Text } from './text';
const selectTv = tv({
slots: {
container: 'mb-4',
label: 'text-grey-100 mb-1 text-lg dark:text-neutral-100',
input:
'border-grey-50 mt-0 flex-row items-center justify-center rounded-xl border-[0.5px] p-3 dark:border-neutral-500 dark:bg-neutral-800',
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 OptionType = { label: string; value: string | number };
type OptionsProps = {
options: OptionType[];
onSelect: (option: OptionType) => void;
value?: string | number;
testID?: string;
};
function keyExtractor(item: OptionType) {
return `select-item-${item.value}`;
}
export const Options = forwardRef<BottomSheetModal, OptionsProps>(
({ options, onSelect, value, testID }, ref) => {
const HEIGHT_MARGIN = 100;
const OPTION_HEIGHT = 70;
const height = options.length * OPTION_HEIGHT + HEIGHT_MARGIN;
const snapPoints = useMemo(() => [height], [height]);
const { colorScheme } = useColorScheme();
const isDark = colorScheme === 'dark';
const renderSelectItem = useCallback(
({ item }: { item: OptionType }) => (
<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 = memo(
({
label,
selected = false,
...props
}: PressableProps & {
selected?: boolean;
label: string;
}) => (
<Pressable
className="flex-row items-center border-b 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?: OptionType[];
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 = useCallback(
(option: OptionType) => {
onSelect?.(option.value);
modal.dismiss();
},
[modal, onSelect],
);
const styles = useMemo(
() =>
selectTv({
error: Boolean(error),
disabled,
}),
[error, disabled],
);
const textValue = 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>
)}
<Pressable
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 />
</Pressable>
{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-form
export 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 = 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 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 { useForm } from 'react-hook-form';
import 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-keyboard-controller pre-installed and configured to handle the keyboard. You only need to check the documentation and use the appropriate approach for your use case. ( note that we already added the KeyboardProvider to the layout in the root file)

Make sure to check the following video for more details on how to handle keyboard in react native: