Form Handling & AJAX

Build reactive forms that talk to Laravel routes. Handle CSRF, loading states, validation errors, and optimistic updates with minimal boilerplate.

The Core Pattern

PawaJS forms stay in the browser. You intercept the submit event, collect data, and send it to a Laravel route with fetch. Laravel responds with JSON.

Always include the CSRF token. Laravel rejects any non-GET request without a valid X-CSRF-TOKEN header.

1. CSRF Token Setup

Laravel already puts the token in a meta tag. Read it once and reuse it for every request.

html

                 
{{-- resources/views/layouts/app.blade.php --}}
<head>
    <meta name="csrf-token" content="{{ csrf_token() }}">
    ...
</head> 

        
js

                 
// resources/js/utils/http.js
export function getCsrfToken() {
    return document.querySelector('meta[name="csrf-token"]')?.content ?? '';
}

export async function api(url, options = {}) {
    const defaults = {
        headers: {
            'Content-Type': 'application/json',
            'Accept': 'application/json',
            'X-CSRF-TOKEN': getCsrfToken(),
            'X-Requested-With': 'XMLHttpRequest',
        },
    };

    const response = await fetch(url, {
        ...defaults,
        ...options,
        headers: {
            ...defaults.headers,
            ...(options.headers || {}),
        },
    });

    const data = await response.json().catch(() => ({}));

    if (!response.ok) {
        // Attach status so callers can distinguish 422 validation errors
        const error = new Error(data.message || 'Request failed');
        error.status = response.status;
        error.errors = data.errors || {};
        throw error;
    }

    return data;
} 

        

2. Simple Create Form

A complete example that creates a new todo and resets the form on success.

js

                 
// resources/js/components/todo-form.js
import { html, $state, useInsert, RegisterComponent } from 'pawajs';
import { api } from '../utils/http';

const TodoForm = () => {
    const title = $state('');
    const loading = $state(false);
    const error = $state(null);
    const success = $state(false);

    const submit = async () => {
        if (!title.value.trim()) return;

        loading.value = true;
        error.value = null;
        success.value = false;

        try {
            await api('/todos', {
                method: 'POST',
                body: JSON.stringify({ title: title.value }),
            });

            title.value = '';
            success.value = true;

            // Optional: dispatch a custom event so parent lists can refresh
            window.dispatchEvent(new CustomEvent('todo:created'));
        } catch (err) {
            error.value = err.errors?.title?.[0] || err.message;
        } finally {
            loading.value = false;
        }
    };

    useInsert({ title, loading, error, success, submit });

    return html`
        <form on-submit.prevent="submit()" class="space-y-4">
            <div>
                <input
                    type="text"
                    value="@{title.value}"
                    on-input="title.value = e.target.value"
                    placeholder="What needs to be done?"
                    class="input w-full"
                    disabled="@{loading.value}"
                >
                <p if="error.value" class="mt-1 text-sm text-red-600">@{error.value}</p>
            </div>

            <button
                type="submit"
                class="btn btn-primary"
                disabled="@{loading.value || !title.value.trim()}"
            >
                @{loading.value ? 'Saving…' : 'Add Todo'}
            </button>

            <p if="success.value" class="text-sm text-green-600">
                Todo created successfully!
            </p>
        </form>
    `;
};

RegisterComponent(TodoForm); 

        

Corresponding Laravel route & controller:

php

                 
// routes/web.php
Route::post('/todos', [TodoController::class, 'store'])
    ->middleware('auth')
    ->name('todos.store');

// app/Http/Controllers/TodoController.php
public function store(Request $request)
{
    $validated = $request->validate([
        'title' => ['required', 'string', 'max:255'],
    ]);

    $todo = $request->user()->todos()->create($validated);

    return response()->json($todo, 201);
} 

        

3. Multi-field Forms

For forms with several fields, keep a single reactive object.

js

                 
const ProfileForm = ({ user }) => {
    const form = $state({
        name: user().name,
        email: user().email,
        bio: user().bio || '',
    });

    const loading = $state(false);
    const errors = $state({});
    const message = $state(null);

    const updateField = (field, value) => {
        form.value = { ...form.value, [field]: value };
        // Clear field error when user starts typing
        if (errors.value[field]) {
            const next = { ...errors.value };
            delete next[field];
            errors.value = next;
        }
    };

    const submit = async () => {
        loading.value = true;
        errors.value = {};
        message.value = null;

        try {
            const data = await api('/profile', {
                method: 'PUT',
                body: JSON.stringify(form.value),
            });

            message.value = 'Profile updated successfully';
            // Optionally update the original user object
        } catch (err) {
            if (err.status === 422) {
                errors.value = err.errors; // Laravel validation bag
            } else {
                message.value = err.message;
            }
        } finally {
            loading.value = false;
        }
    };

    useInsert({ form, loading, errors, message, updateField, submit });

    return html`
        <form on-submit.prevent="submit()" class="space-y-5 max-w-md">
            <div>
                <label class="label">Name</label>
                <input
                    class="input w-full"
                    value="@{form.value.name}"
                    on-input="updateField('name', e.target.value)"
                >
                <p if="errors.value.name" class="error-text">@{errors.value.name[0]}</p>
            </div>

            <div>
                <label class="label">Email</label>
                <input
                    type="email"
                    class="input w-full"
                    value="@{form.value.email}"
                    on-input="updateField('email', e.target.value)"
                >
                <p if="errors.value.email" class="error-text">@{errors.value.email[0]}</p>
            </div>

            <div>
                <label class="label">Bio</label>
                <textarea
                    class="input w-full"
                    rows="3"
                    on-input="updateField('bio', e.target.value)"
                >@{form.value.bio}</textarea>
            </div>

            <button type="submit" class="btn btn-primary" disabled="@{loading.value}">
                @{loading.value ? 'Saving…' : 'Save Changes'}
            </button>

            <p if="message.value" class="text-sm text-green-600">@{message.value}</p>
        </form>
    `;
};

RegisterComponent(ProfileForm); 

        

4. Optimistic Updates

Update the UI immediately, then roll back if the server rejects the change.

js

                 
const TodoItem = ({ todo, onRemove }) => {
    const item = $state({ ...todo() });
    const deleting = $state(false);

    const toggle = async () => {
        const previous = item.value.completed;
        item.value = { ...item.value, completed: !previous }; // optimistic

        try {
            await api(`/todos/${item.value.id}`, {
                method: 'PATCH',
                body: JSON.stringify({ completed: item.value.completed }),
            });
        } catch {
            item.value = { ...item.value, completed: previous }; // rollback
        }
    };

    const remove = async () => {
        deleting.value = true;
        try {
            await api(`/todos/${item.value.id}`, { method: 'DELETE' });
            onRemove()(item.value.id); // tell parent to remove from list
        } catch {
            deleting.value = false;
        }
    };

    useInsert({ item, deleting, toggle, remove });

    return html`
        <li class="flex items-center gap-3 py-2" class="@{deleting.value ? 'opacity-50' : ''}">
            <input
                type="checkbox"
                checked="@{item.value.completed}"
                on-change="toggle()"
            >
            <span class="@{item.value.completed ? 'line-through text-muted-foreground' : ''}">
                @{item.value.title}
            </span>
            <button on-click="remove()" class="ml-auto text-sm text-red-500">
                Delete
            </button>
        </li>
    `;
};

RegisterComponent(TodoItem); 

        

5. File Uploads

Use FormData instead of JSON when sending files.

js

                 
const AvatarUpload = () => {
    const preview = $state(null);
    const loading = $state(false);
    const error = $state(null);

    const onFileChange = (e) => {
        const file = e.target.files[0];
        if (!file) return;

        preview.value = URL.createObjectURL(file);
        upload(file);
    };

    const upload = async (file) => {
        loading.value = true;
        error.value = null;

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

        try {
            const data = await api('/profile/avatar', {
                method: 'POST',
                headers: {
                    // Let the browser set Content-Type with boundary
                    'Content-Type': undefined,
                },
                body,
            });

            // data.url contains the new avatar URL
        } catch (err) {
            error.value = err.errors?.avatar?.[0] || err.message;
            preview.value = null;
        } finally {
            loading.value = false;
        }
    };

    useInsert({ preview, loading, error, onFileChange });

    return html`
        <div class="space-y-3">
            <img if="preview.value" src="@{preview.value}" class="w-24 h-24 rounded-full object-cover" />

            <label class="btn cursor-pointer">
                @{loading.value ? 'Uploading…' : 'Choose Avatar'}
                <input
                    type="file"
                    accept="image/*"
                    class="hidden"
                    on-change="onFileChange(e)"
                    disabled="@{loading.value}"
                >
            </label>

            <p if="error.value" class="text-sm text-red-600">@{error.value}</p>
        </div>
    `;
};

RegisterComponent(AvatarUpload); 

        

Best Practices

  • Always use on-submit.prevent — Prevents the browser from doing a full page reload.
  • Return JSON from Laravel — Use response()->json() or API resources. Avoid returning Blade views for AJAX endpoints.
  • Handle 422 specifically — Laravel validation errors come as { message, errors: { field: [msgs] } }.
  • Disable buttons while loading — Prevents double submissions.
  • Clear field errors on input — Gives immediate feedback that the user is correcting a problem.
  • Prefer PATCH/PUT for updates — Match Laravel resource conventions.

Quick Reference

Prevent default
on-submit.prevent="save()"
Disable while loading
disabled="@{loading.value}"
Show validation error
if="errors.value.email"
CSRF header
X-CSRF-TOKEN: token