next.jsreact

Server Actionsit Next.jsissa: lomake, joka toimii ennen kuin JavaScript on edes latautunut

Server Actionsit Next.jsissa: lomake, joka toimii ennen kuin JavaScript on edes latautunut

Klassinen lomakekaava Reactissa: onSubmit, event.preventDefault(), fetch('/api/todos', { method: 'POST', body: JSON.stringify(...) }), useState lataustilan hoitamiseen. Se toimii – yhdellä ehdolla, joka on helppo unohtaa, koska omalla nopealla kannettavalla se ei koskaan paljastu: JavaScriptin täytyy ehtiä latautua, jäsentyä ja suorittua, ennen kuin lomake ylipäätään alkaa reagoida klikkaukseen. Hitaalla mobiiliyhteydellä, ison sivun hydraation aikana, tai kun selainlaajennus estää skriptin – käyttäjä klikkaa "Lähetä", eikä kirjaimellisesti mitään tapahdu. Ei virhettä, ei latausikonia – onClick ei yksinkertaisesti ole vielä olemassa.

Server Actionsit Next.jsissa esitetään usein pelkkänä mukavampana syntaksina täsmälleen samalle kutsulle – "fetch, paitsi ettei tarvitse kirjoittaa API-reittiä". Tämä yksinkertaistus hukkaa sen, mikä mekanismissa on todella uutta: <form action={omaAktio}> nojaa selaimen natiiviin, aina olemassa olleeseen lomakkeenlähetysmekanismiin, ei JavaScriptiin välttämättömänä ehtona. Tämä muuttaa sekä sitä, miten se toimii konepellin alla, että sitä, minkälaisia virheitä käytännössä tekevät tiimit, jotka kohtelevat "use server"-merkintää kuin tavallista paikallisen funktion kutsua.

Mitä "use server"-direktiivi oikeasti tekee

Kun merkitset funktion "use server"-direktiivillä, Next.jsin kääntäjä ei jätä sen runkoa selaimen saamaan pakettiin. Se leikataan pois ja korvataan referenssillä – salatulla tunnisteella, joka osoittaa, mitä funktiota palvelimella pitää kutsua. Se, mikä todellisuudessa päätyy asiakkaalle, on pieni tynkä: "kun minua kutsutaan, lähetä POST-pyyntö erityiseen RSC-päätepisteeseen tällä tunnisteella ja serialisoiduilla argumenteilla". Itse logiikka – tietokantakysely, validointi, mitä funktio ikinä tekeekin – ei koskaan fyysisesti poistu palvelimelta.

tsx

// app/actions.ts
"use server";
import { revalidatePath } from "next/cache";
import { db } from "@/lib/db";
export async function addTodoAction(prevState: unknown, formData: FormData) {
const title = formData.get("title");
if (typeof title !== "string" || title.trim().length === 0) {
return { error: "Otsikko ei voi olla tyhjä." };
}
await db.todo.create({ data: { title: title.trim() } });
revalidatePath("/todos");
return { error: null };
}

Kun kiinnität tämän funktion elementtiin <form action={addTodoAction}>, tapahtuu jotain, mitä tavallinen fetch ei koskaan antanut ilmaiseksi: se toimii ennen kuin yhtäkään riviä JavaScriptia on suoritettu. Selain on aina osannut lähettää lomakkeen natiivina POST-pyyntönä, jos action-attribuutti osoittaa URL-osoitteeseen – React 19 ja Next.js laajentavat tätä niin, että action voi olla myös palvelinfunktio, ja selain silti suorittaa oman natiivin polkunsa, jos JS ei ole vielä valmis. Kun JavaScript sitten toimii, Next.js kaappaa tapahtuman ja tekee saman asian fetch-kutsulla taustalla, ilman sivun uudelleenlatausta, sulavalla käyttöliittymän päivityksellä. Tämä on frameworkiin sisäänrakennettu progressiivinen parannus, ei jotain, joka pitäisi itse ohjelmoida – täsmälleen sama mekanismi, josta kirjoitin RouteAnnouncer-komponentin yhteydessä saavutettavuusartikkelissa, mutta sovellettuna datan muokkaukseen navigoinnin sijaan.

Käytännön esimerkki: lomake täydellä tilanhallinnalla, ilman käsin kirjoitettua fetchia

React 19 lisäsi useActionState-hookin nimenomaan tätä kaavaa varten – se yhdistää palvelinaktion kutsun sen viimeisimmän suorituksen palauttamaan tilaan, ilman käsin kirjoitettua useState-tilaa virheen tai lataustilan säilyttämiseen.

tsx

"use client";
import { useActionState } from "react";
import { addTodoAction } from "./actions";
const initialState = { error: null };
const TodoForm = () => {
const [state, formAction, isPending] = useActionState(
addTodoAction,
initialState,
);
return (
<form action={formAction}>
<input type="text" name="title" placeholder="Uusi tehtävä" required />
<button type="submit" disabled={isPending}>
{isPending ? "Lisätään…" : "Lisää"}
</button>
{state.error && <p className="form-error">{state.error}</p>}
</form>
);
};
export default TodoForm;

Tässä tapahtuu useita asioita samanaikaisesti, ilman ainuttakaan riviä koodia, joka olisi omistettu pyynnön käsin hallinnalle. isPending heijastaa lomakkeen lähetyksen todellista tilaa – mukaan lukien tuo natiivi, ennen hydraatiota tapahtuva POST-pyyntö, ei pelkkää asiakaspuolen kutsua. state.error on suoraan se, mitä palvelinfunktio palautti – ei JSON-vastauksen jäsentämistä, ei try/catch-lohkoa fetch-kutsun ympärillä. revalidatePath("/todos") aktion sisällä käskee Next.jsia päivittämään tämän reitin välimuistin, joten sivun tehtävälista näyttää uuden merkinnän ilman käsin tehtyä asiakastilan päivitystä – framework, ei sinä, päättää milloin ja miten uusi RSC-payload haetaan.

Sudenkuopat ja hyvät käytännöt

  • Jokainen "use server"-funktio on julkinen HTTP-päätepiste – ei yksityinen funktio. Tämä on yleisin tietoturvaharha Server Actioneissa: koska mikään käyttöliittymässä ei suoraan linkitä siihen, se tuntuu "piilotetulta". Ei ole – kääntäjä luo sille pysyvän, ulkopuolelta kutsuttavan aktiotunnisteen, joten kuka tahansa, joka sen saa selville (esim. tarkkailemalla verkkoliikennettä DevToolsissa), voi kutsua sitä suoraan, ohittaen käyttöliittymäsi ja sen oletukset. Syötteen validointi ja oikeuksien tarkistus (sessio, käyttäjän rooli) täytyy tehdä itse aktion sisällä, aivan kuten klassisessa API-päätepisteessä – älä koskaan luota siihen, että "painike ei näy kirjautumattomille".
  • Argumenttien ja paluuarvon täytyy olla serialisoitavissa. Server Actionsit siirtävät dataa verkon yli Reactin omalla serialisointiformaatilla (rikkaammalla kuin JSON – se tukee mm. Date-, Map- ja suoraan lomakkeesta välitettyä FormData-tyyppiä), mutta se ei silti päästä läpi funktioita, luokkien instansseja tai viittauksia DOM-objekteihin. Jos käytät .bind()-metodia sitomaan ylimääräisen argumentin asiakaspuolen kutsuun (addTodoAction.bind(null, listId)), muista, että myös listId-arvon täytyy olla serialisoitavissa.
  • Tämä on silti aito verkkopyyntö, ei paikallinen funktiokutsu. Tämä on helppo unohtaa, koska syntaktisesti await omaAktio(data) näyttää identtiseltä tavallisen JS-funktion kutsun kanssa. Konepellin alla kyse on aina täydestä edestakaisesta matkasta: serialisointi, HTTP, deserialisointi toisessa päässä. Server Actionin kutsuminen silmukassa jokaisen tekstikenttään kirjoitetun merkin jälkeen (esim. reaaliaikaiseen validointiin) muuttaa lomakkeesi pyyntögeneraattoriksi, ei responsiiviseksi syötekentäksi – tällaisissa tapauksissa debounce on yhtä välttämätön kuin missä tahansa muussa verkkokutsussa.
  • revalidatePath/revalidateTag-kutsun puuttuminen jättää käyttöliittymän vanhentuneen välimuistin varaan. Next.js välimuistittaa sivujen dataa oletusarvoisesti – aktio, joka tallentaa jotain tietokantaan mutta ei kerro frameworkille, mikä reitti pitää päivittää, johtaa usein ilmoitukseen "tallentui, mutta ei näy" – data on tietokannassa oikein, mutta käyttöliittymä näyttää yhä sivun vanhaa, välimuistiin tallennettua versiota.

Yhteenveto

Server Actionsit eivät ole "fetch kauniimmalla syntaksilla" – ne ovat kaksi eri asiaa, jotka on rakennettu saman mekanismin päälle. Ensinnäkin ne nojaavat selaimen natiiviin lomakkeenlähetykseen, joten lomake toimii jopa ennen kuin JavaScript ehtii latautua – sinun ei tarvitse kirjoittaa ainuttakaan riviä koodia tämän käytöksen aikaansaamiseksi, saat sen suoraan <form action={...}>-arkkitehtuurista. Toiseksi, ja tietoturvan kannalta tärkeämpää: jokaisesta "use server"-merkinnällä varustetusta funktiosta tulee aito, ulkopuolelta kutsuttava päätepiste verkossa, riippumatta siitä, kutsuuko sitä mikään näkyvä käyttöliittymäelementti. Sen kohteleminen paikallisena, luotettuna funktiona – ilman validointia ja valtuutusta sisällä – on helpoin tapa muuttaa mukava syntaksi suojaamattomaksi API-päätepisteeksi.