Form Engine

useForm()

useForm is a controlled form hook. It requires a Zod schema. Import z from the SDK — no separate zod install needed.

import { useForm, z } from '@objectifthunes/limestone-sdk';

const schema = z.object({
  email: z.string().email('Enter a valid email'),
  password: z.string().min(8, 'Password must be at least 8 characters'),
});

function LoginForm() {
  const form = useForm({
    schema,
    defaultValues: { email: '', password: '' },
    onSubmit: async (values) => {
      const api = useApi(); // or pass via closure
      await api.post('/sessions', { body: values, skipAuth: true });
    },
  });
}

Config

FieldTypeRequiredDescription
schemaZodObject<T>YesZod schema that defines the shape and validation rules
defaultValuesRecord<keyof T, string>YesInitial field values
onSubmit(values: T) => Promise<void>YesCalled after full-form validation passes

UseFormReturn fields

const form = useForm({ schema, defaultValues, onSubmit });
FieldTypeDescription
valuesRecord<keyof T, string>Current field values
errorsPartial<Record<keyof T, string>>Error message per field, or empty
isDirtybooleanTrue once any setValue has been called
isSubmittingbooleanTrue while onSubmit promise is pending
setValue(field, value)voidUpdate one field, marks isDirty = true
validateField(field)voidParse only that field’s schema; updates its error
handleSubmit()Promise<void>Validate all fields, then call onSubmit if valid
reset()voidRestore defaultValues, clear all errors and flags
setServerErrors(errors)voidMerge a Partial<Record<keyof T, string>> into errors

Field-level validation

Call validateField in an onBlur handler to give immediate feedback as the user leaves a field.

<TextInput
  value={form.values.email}
  onChangeText={(v) => form.setValue('email', v)}
  onBlur={() => form.validateField('email')}
/>
{form.errors.email ? <Text style={styles.error}>{form.errors.email}</Text> : null}

validateField parses only that field using the schema. It does not block or trigger submission.

Form-level validation

handleSubmit runs the full schema parse. If any field fails, errors are populated and onSubmit is not called.

<Pressable onPress={form.handleSubmit} disabled={form.isSubmitting}>
  <Text>{form.isSubmitting ? 'Signing in…' : 'Sign in'}</Text>
</Pressable>

While isSubmitting is true, the button is disabled and the form ignores additional handleSubmit calls.

Server error merging

After a failed API call, use setServerErrors to display backend validation messages inline.

const form = useForm({
  schema,
  defaultValues: { email: '', password: '' },
  onSubmit: async (values) => {
    try {
      await api.post('/sessions', { body: values, skipAuth: true });
      router.push('/home');
    } catch (e) {
      // API returns { errors: { email: 'Already in use' } }
      const body = (e as any).body;
      if (body?.errors) {
        form.setServerErrors(body.errors);
      }
    }
  },
});

Server errors merge into form.errors alongside any client-side errors. A subsequent setValue on the same field clears that field’s error.

Reset after navigation

Call reset() when the form is dismissed or the user navigates away to avoid stale state.

React.useEffect(() => {
  return () => { form.reset(); };
}, []);

Complete login form example

import React from 'react';
import { View } from 'react-native';
import { useForm, useApi, useTheme, z } from '@objectifthunes/limestone-sdk';
import { TextInput, Pressable, Text, Stack } from '@objectifthunes/limestone-sdk';

const loginSchema = z.object({
  email: z.string().email('Enter a valid email address'),
  password: z.string().min(8, 'Password must be at least 8 characters'),
});

export function LoginForm({ onSuccess }: { onSuccess: () => void }) {
  const api = useApi();
  const { tokens } = useTheme();

  const form = useForm({
    schema: loginSchema,
    defaultValues: { email: '', password: '' },
    onSubmit: async (values) => {
      try {
        await api.post('/sessions', { body: values, skipAuth: true });
        onSuccess();
      } catch (e) {
        form.setServerErrors({ email: 'Invalid email or password' });
      }
    },
  });

  return (
    <Stack gap="md">
      <TextInput
        label="Email"
        value={form.values.email}
        onChangeText={(v) => form.setValue('email', v)}
        onBlur={() => form.validateField('email')}
        error={form.errors.email}
        keyboardType="email-address"
        autoCapitalize="none"
      />

      <TextInput
        label="Password"
        value={form.values.password}
        onChangeText={(v) => form.setValue('password', v)}
        onBlur={() => form.validateField('password')}
        error={form.errors.password}
        secureTextEntry
      />

      <Pressable
        onPress={form.handleSubmit}
        disabled={form.isSubmitting}
        bg="primary"
        rounded="md"
        p="md"
        center
      >
        <Text color="primaryForeground" weight="semibold">
          {form.isSubmitting ? 'Signing in…' : 'Sign in'}
        </Text>
      </Pressable>
    </Stack>
  );
}

TypeScript inference

The schema type flows through to values, errors, setValue, validateField, and setServerErrors. You get full autocomplete on field names.

// TypeScript error: 'username' is not a key of loginSchema
form.setValue('username', '');

// TypeScript error: argument type mismatch
form.setServerErrors({ unknownField: 'bad' });