Forms & Data

Forms

Building and handling forms with validation, submission, and error states in Manic.

Forms

TL;DR

Manic uses standard HTML forms with React state for managed inputs. Submit to API routes for server-side processing. Use controlled components for complex forms and validation.

What It Is

Form handling in Manic follows standard React patterns:

PatternUse Case
UncontrolledSimple forms with FormData
ControlledComplex forms with validation
Server ActionsForm submission to API

Flow:

  1. User fills form
  2. Client validates (optional)
  3. Submit to API route
  4. Server processes
  5. Return result/errors

Prerequisites


Quick Start

Simple Form

// app/routes/contact.tsx
import React, { useState } from 'react';

export default function ContactPage() {
  const [status, setStatus] = useState<'idle' | 'submitting' | 'success' | 'error'>('idle');

  const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault();
    setStatus('submitting');

    const formData = new FormData(e.currentTarget);
    const data = Object.fromEntries(formData);

    const res = await fetch('/api/contact', {
      method: 'POST',
      body: JSON.stringify(data),
      headers: { 'Content-Type': 'application/json' },
    });

    setStatus(res.ok ? 'success' : 'error');
  };

  return (
    <form onSubmit={handleSubmit}>
      <input name="email" type="email" required />
      <textarea name="message" required />
      <button type="submit" disabled={status === 'submitting'}>
        {status === 'submitting' ? 'Sending...' : 'Send'}
      </button>
      {status === 'success' && <p>Message sent!</p>}
    </form>
  );
}

How It Works

Form Submission Flow

Initializing diagram...

Examples

Example 1: Controlled Form with Validation

// app/routes/register.tsx
import React, { useState } from 'react';

interface FormErrors {
  email?: string;
  password?: string;
}

export default function RegisterPage() {
  const [form, setForm] = useState({ email: '', password: '' });
  const [errors, setErrors] = useState<FormErrors>({});
  const [status, setStatus] = useState<'idle' | 'submitting' | 'success' | 'error'>('idle');

  const validate = (): boolean => {
    const newErrors: FormErrors = {};

    if (!form.email.includes('@')) {
      newErrors.email = 'Invalid email address';
    }

    if (form.password.length < 8) {
      newErrors.password = 'Password must be at least 8 characters';
    }

    setErrors(newErrors);
    return Object.keys(newErrors).length === 0;
  };

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();

    if (!validate()) return;

    setStatus('submitting');

    const res = await fetch('/api/register', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(form),
    });

    setStatus(res.ok ? 'success' : 'error');
  };

  const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    setForm({ ...form, [e.target.name]: e.target.value });
  };

  return (
    <form onSubmit={handleSubmit}>
      <div>
        <input
          name="email"
          type="email"
          value={form.email}
          onChange={handleChange}
        />
        {errors.email && <span>{errors.email}</span>}
      </div>

      <div>
        <input
          name="password"
          type="password"
          value={form.password}
          onChange={handleChange}
        />
        {errors.password && <span>{errors.password}</span>}
      </div>

      <button type="submit" disabled={status === 'submitting'}>
        Register
      </button>
    </form>
  );
}

Example 2: Form with Reset

// app/routes/search.tsx
import React, { useState } from 'react';

export default function SearchPage() {
  const [query, setQuery] = useState('');

  const handleReset = () => {
    setQuery('');
  };

  return (
    <form method="get" action="/search">
      <input
        name="q"
        value={query}
        onChange={(e) => setQuery(e.target.value)}
      />
      <button type="submit">Search</button>
      <button type="button" onClick={handleReset}>Clear</button>
    </form>
  );
}

Example 3: Multi-Part Form

// app/routes/upload.tsx
import React, { useState } from 'react';

export default function UploadPage() {
  const [file, setFile] = useState<File | null>(null);

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();

    if (!file) return;

    const formData = new FormData();
    formData.append('file', file);

    await fetch('/api/upload', {
      method: 'POST',
      body: formData,
    });
  };

  return (
    <form onSubmit={handleSubmit}>
      <input
        type="file"
        onChange={(e) => setFile(e.target.files?.[0] ?? null)}
      />
      <button type="submit">Upload</button>
    </form>
  );
}

Example 4: Login Form

// app/routes/login.tsx
import React, { useState } from 'react';
import { Link, useRouter } from 'manicjs';

export default function LoginPage() {
  const [form, setForm] = useState({ email: '', password: '' });
  const [error, setError] = useState('');
  const [isLoading, setIsLoading] = useState(false);
  const router = useRouter();

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    setIsLoading(true);
    setError('');

    try {
      const res = await fetch('/api/auth/login', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(form),
      });

      if (!res.ok) {
        const data = await res.json();
        setError(data.error || 'Login failed');
        return;
      }

      router.navigate('/dashboard');
    } catch (e) {
      setError('An error occurred');
    } finally {
      setIsLoading(false);
    }
  };

  const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    setForm({ ...form, [e.target.name]: e.target.value });
  };

  return (
    <form onSubmit={handleSubmit}>
      {error && <div className="error">{error}</div>}

      <input
        name="email"
        type="email"
        placeholder="Email"
        value={form.email}
        onChange={handleChange}
        required
      />

      <input
        name="password"
        type="password"
        placeholder="Password"
        value={form.password}
        onChange={handleChange}
        required
      />

      <button type="submit" disabled={isLoading}>
        {isLoading ? 'Logging in...' : 'Login'}
      </button>

      <p>
        Don't have an account? <a href="/register">Register</a>
      </p>
    </form>
  );
}

Example 5: Server-Side Validation

// app/api/contact/index.ts
export async function POST({ request }: { request: Request }) {
  const data = await request.json();
  const errors: Record<string, string> = {};

  // Validate email
  if (!data.email || !data.email.includes('@')) {
    errors.email = 'Valid email is required';
  }

  // Validate message
  if (!data.message || data.message.length < 10) {
    errors.message = 'Message must be at least 10 characters';
  }

  // Return errors if any
  if (Object.keys(errors).length > 0) {
    return Response.json({ errors }, { status: 400 });
  }

  // Process form
  await saveMessage(data);

  return Response.json({ success: true });
}

Advanced Patterns

Pattern 1: Debounced Field Validation

// Real-time validation with debounce
import React, { useState, useCallback, useRef } from 'react';

function useDebounce<T>(value: T, ms: number) {
  const [debounced, setDebounced] = useState(value);
  const timeoutRef = useRef<NodeJS.Timeout>();

  React.useEffect(() => {
    timeoutRef.current = setTimeout(() => setDebounced(value), ms);
    return () => clearTimeout(timeoutRef.current);
  }, [value, ms]);

  return debounced;
}

export function RegisterForm() {
  const [email, setEmail] = useState('');
  const [emailError, setEmailError] = useState('');
  const debouncedEmail = useDebounce(email, 500);

  React.useEffect(() => {
    if (!debouncedEmail) return;
    
    checkEmailAvailable(debouncedEmail).then(available => {
      if (!available) setEmailError('Email already in use');
      else setEmailError('');
    });
  }, [debouncedEmail]);

  return (
    <form>
      <input value={email} onChange={(e) => setEmail(e.target.value)} />
      {emailError && <span className="error">{emailError}</span>}
    </form>
  );
}

Pattern 2: Auto-Save Drafts with LocalStorage

// Save form state to localStorage with recovery
import React, { useState, useEffect } from 'react';

function useDraft(key: string, initialValue: object) {
  const [form, setForm] = useState(() => {
    try {
      const saved = localStorage.getItem(key);
      return saved ? JSON.parse(saved) : initialValue;
    } catch {
      return initialValue;
    }
  });

  useEffect(() => {
    const timer = setTimeout(() => {
      localStorage.setItem(key, JSON.stringify(form));
    }, 1000);

    return () => clearTimeout(timer);
  }, [form, key]);

  return [form, setForm] as const;
}

export function ArticleForm() {
  const [form, setForm] = useDraft('article-draft', { title: '', content: '' });

  return (
    <form>
      <input 
        value={form.title} 
        onChange={(e) => setForm({ ...form, title: e.target.value })}
      />
      <textarea 
        value={form.content} 
        onChange={(e) => setForm({ ...form, content: e.target.value })}
      />
      <p className="text-xs text-gray-500">Draft auto-saved</p>
    </form>
  );
}

Common Issues

Issue 1: Form Not Submitting

Problem: Form submits but nothing happens.

Check:

  1. e.preventDefault() is called
  2. Form has onSubmit handler
  3. Button has type="submit"
// ✗ BAD: Missing preventDefault
const handleSubmit = (e) => {
  // default behavior occurs
};

// ✓ GOOD: Prevent default
const handleSubmit = (e) => {
  e.preventDefault();
  // handle submit
};

Issue 2: Validation Not Working

Problem: Server returns errors but they're not displayed.

Solution:

const handleSubmit = async (e) => {
  e.preventDefault();

  const res = await fetch('/api/submit', { ... });
  const data = await res.json();

  if (!res.ok && data.errors) {
    setErrors(data.errors);  // Display server errors
  }
};

Best Practices

Validate client-side for better UX, but always validate server-side for security.

Always validate user input server-side, even if client-side validation passes.

Use type="email" and other HTML5 validation attributes for basic validation.

Show loading states — disable buttons during submission to prevent double-submit.

Display clear error messages — tell users what went wrong and how to fix it.


See also:

On this page