DEV Community

Cover image for Zero-JS Mutations: Progressive Enhancement in Next.js 🌐
Prajapati Paresh
Prajapati Paresh

Posted on Originally published at smarttechdevs.in

Zero-JS Mutations: Progressive Enhancement in Next.js 🌐

The Fragility of the Client-Side SPA

Over the last decade, the Single Page Application (SPA) architecture trained developers to completely bypass native web standards. To submit a form, we typically call e.preventDefault(), serialize the inputs into a JSON object, and fire off an axios.post() request.

This architecture is incredibly fragile. If the user is on a spotty 3G connection and the 2MB JavaScript bundle fails to download, the form is completely dead. If a third-party script throws an unhandled exception and crashes the React runtime, the submit button stops working. We have built complex enterprise interfaces that completely shatter the moment JavaScript encounters a network anomaly.

At Smart Tech Devs, we architect resilient frontend systems that respect the fundamental mechanics of the web. Utilizing the Next.js App Router and React Server Components (RSC), we embrace Progressive Enhancement. Our forms and mutations work perfectly with pure HTML and zero JavaScript, but automatically "enhance" themselves to provide instantaneous, app-like interactivity once the JS bundle successfully hydrates.

The Philosophy of Progressive Enhancement

Progressive enhancement flips the SPA model upside down. You start by building the feature using core web technologies (HTML <form> tags and standard HTTP POST requests). Once the baseline functionality is mathematically guaranteed to work under any condition, you layer JavaScript on top to improve the UX (preventing full page reloads, adding optimistic UI, and showing loading spinners).

Phase 1: The Server Action Foundation

Next.js Server Actions allow us to define backend mutations directly alongside our UI. Because they generate native API endpoints under the hood, we can pass them directly into the action attribute of a standard HTML form.


// app/actions/userActions.ts
'use server';

import { db } from '@/lib/db';
import { revalidatePath } from 'next/cache';

// This function acts as our form handler. 
// It receives the native FormData object directly.
export async function updateProfile(prevState: any, formData: FormData) {
    const name = formData.get('name') as string;
    const email = formData.get('email') as string;

    if (!name || !email) {
        return { error: 'All fields are strictly required.' };
    }

    try {
        await db.user.update({
            where: { email },
            data: { name }
        });
        
        revalidatePath('/profile');
        return { success: 'Profile updated successfully!' };
    } catch (e) {
        return { error: 'Database timeout. Please try again.' };
    }
}

Phase 2: Architecting the Resilient Form

To wire this up in a client component, React 19 provides the useActionState (formerly useFormState) hook. This manages the lifecycle of the Server Action, returning the current state (errors or success messages) and a specialized action reference to bind to the form.


// app/profile/ProfileForm.tsx
'use client';

import { useActionState } from 'react';
import { updateProfile } from '@/app/actions/userActions';
import SubmitButton from './SubmitButton';

export default function ProfileForm() {
    // 1. Initialize the state machine for the Server Action
    const [state, formAction, isPending] = useActionState(updateProfile, null);

    return (
        /* 2. Bind the formAction natively. NO onSubmit or e.preventDefault() needed! */
        <form action={formAction} className="max-w-md mx-auto p-6 bg-white rounded shadow">
            <h2 className="text-2xl font-bold mb-4">Enterprise Profile Settings</h2>

            {/* Render validation errors returned from the server */}
            {state?.error && (
                <div className="mb-4 p-3 bg-red-100 text-red-700 rounded">
                    {state.error}
                </div>
            )}
            
            {state?.success && (
                <div className="mb-4 p-3 bg-green-100 text-green-700 rounded">
                    {state.success}
                </div>
            )}

            <div className="mb-4">
                <label className="block text-gray-700 mb-2">Full Name</label>
                <input type="text" name="name" required className="w-full p-2 border rounded" />
            </div>

            <div className="mb-6">
                <label className="block text-gray-700 mb-2">Email Address</label>
                <input type="email" name="email" required className="w-full p-2 border rounded" />
            </div>

            {/* 3. Extract the submit button to handle pending states gracefully */}
            <SubmitButton />
        </form>
    );
}

Phase 3: The Contextual Submit Button

Because the form relies on native submission behavior, we must extract the submit button into a separate component and use the useFormStatus hook. This hook automatically reads the pending state of the parent <form> without requiring prop drilling or React Context boilerplate.


// app/profile/SubmitButton.tsx
'use client';

import { useFormStatus } from 'react-dom';

export default function SubmitButton() {
    // 1. Automatically detects if the parent  is currently submitting
    const { pending } = useFormStatus();

    return (
        <button 
            type="submit" 
            disabled={pending}
            className="w-full bg-blue-600 text-white font-bold py-2 px-4 rounded disabled:opacity-50"
        >
            {pending ? 'Encrypting & Saving...' : 'Update Profile'}
        </button>
    );
}

The Engineering ROI and Graceful Degradation

By architecting your frontend mutations around Server Actions and native form controls, you achieve true Graceful Degradation. If a user visits this page and their corporate firewall violently blocks your JavaScript bundle, the HTML <form> still functions perfectly. The browser natively executes a standard HTTP POST request to the Next.js server, updates the database, and reloads the page with the success message.

If the JavaScript bundle loads successfully, React instantly intercepts the form, overrides the default browser navigation, executes the mutation via fetch, and updates the UI seamlessly without a page reload. You get the unshakeable reliability of 1999 web standards combined with the instantaneous, zero-latency UX of modern React architecture, ensuring your enterprise software never breaks for the end user.

Top comments (0)