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 * 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 dark:text-neutral-100 text-lg mb-1',
input:
'mt-0 border-[0.5px] font-jakarta text-base leading-5 font-[500] px-4 py-3 rounded-xl bg-neutral-100 border-neutral-300 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-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 * 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-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 = 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 />;
};